feat: auto-authorize policies when adding projects to sub-accounts
- Disable now removes all policies (saved to DB) + Enable restores them - Project add: policies are now user-selected (checkbox), not auto-attached - Fix serializer allow_blank for optional fields (email/phone/password) - Better error reporting for policy detach/attach failures - Handle duplicate user creation with clear error message Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4f61a4ada
commit
a7e030dc57
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.21 on 2026-03-20 06:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('monitor', '0005_globalconfig_default_project_policies_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='iamuser',
|
||||
name='saved_policies_on_disable',
|
||||
field=models.JSONField(blank=True, default=list, help_text='停用时自动移除的策略列表,恢复时加回', verbose_name='停用时保存的策略'),
|
||||
),
|
||||
]
|
||||
@ -56,6 +56,10 @@ class IAMUser(models.Model):
|
||||
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
|
||||
help_text='记录已通知过的百分比,划拨新额度时自动重置')
|
||||
|
||||
# --- 停用时保存的策略快照(恢复时自动加回) ---
|
||||
saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True,
|
||||
help_text='停用时自动移除的策略列表,恢复时加回')
|
||||
|
||||
remark = models.TextField('备注', blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@ -57,11 +57,11 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class IAMUserCreateSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=200)
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
email = serializers.EmailField(required=False, default='')
|
||||
phone = serializers.CharField(max_length=20, required=False, default='')
|
||||
password = serializers.CharField(write_only=True, required=False, default='')
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='',
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
email = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
phone = serializers.CharField(max_length=20, required=False, default='', allow_blank=True)
|
||||
password = serializers.CharField(write_only=True, required=False, default='', allow_blank=True)
|
||||
project_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True,
|
||||
help_text='可选,创建后自动关联此项目')
|
||||
|
||||
|
||||
@ -80,8 +80,13 @@ class IAMUserConfigSerializer(serializers.Serializer):
|
||||
|
||||
class IAMUserProjectAddSerializer(serializers.Serializer):
|
||||
project_name = serializers.CharField(max_length=200)
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='')
|
||||
display_name = serializers.CharField(max_length=200, required=False, default='', allow_blank=True)
|
||||
monitor_enabled = serializers.BooleanField(required=False, default=True)
|
||||
policies = serializers.ListField(
|
||||
child=serializers.CharField(max_length=200),
|
||||
required=False, default=list,
|
||||
help_text='要在项目范围内授权的策略名列表,如 ["ArkFullAccess"]'
|
||||
)
|
||||
|
||||
|
||||
class IAMUserProjectUpdateSerializer(serializers.Serializer):
|
||||
|
||||
@ -234,6 +234,10 @@ def iam_user_create_view(request):
|
||||
phone=d.get('phone', ''),
|
||||
)
|
||||
except VolcengineAPIError as e:
|
||||
if 'UserAlreadyExists' in e.code or 'EntityAlreadyExists' in e.code:
|
||||
return Response({'error': 'user_exists',
|
||||
'message': f"火山引擎上已存在用户 {d['username']},请使用「同步已有用户」导入"},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
return Response({'error': 'create_failed', 'message': f'创建用户失败: {e}'},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
@ -377,16 +381,43 @@ def iam_user_disable_view(request, pk):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
# 1. 停用控制台 + API 密钥
|
||||
svc.disable_user(user.username)
|
||||
|
||||
# 2. 移除所有权限策略并保存快照(恢复时加回)
|
||||
saved_policies = []
|
||||
detach_errors = []
|
||||
try:
|
||||
resp = svc.list_attached_user_policies(user.username)
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
for p in policies:
|
||||
pname = p.get("PolicyName", "")
|
||||
ptype = p.get("PolicyType", "")
|
||||
try:
|
||||
svc.detach_user_policy(user.username, pname, ptype)
|
||||
saved_policies.append({"name": pname, "type": ptype})
|
||||
except VolcengineAPIError as detach_err:
|
||||
detach_errors.append(f"{pname}: {detach_err}")
|
||||
except VolcengineAPIError:
|
||||
pass
|
||||
|
||||
user.status = IAMUser.Status.DISABLED
|
||||
user.save(update_fields=['status'])
|
||||
user.saved_policies_on_disable = saved_policies
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
|
||||
policy_count = len(saved_policies)
|
||||
error_info = f",移除失败: {detach_errors}" if detach_errors else ""
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"手动停用子账号 {user.username}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
content=f"操作人: {request.user.username},已移除 {policy_count} 个权限策略{error_info}",
|
||||
)
|
||||
return Response({'message': f'用户 {user.username} 已停用'})
|
||||
msg = f'用户 {user.username} 已停用,{policy_count} 个权限策略已移除'
|
||||
result = {'message': msg}
|
||||
if detach_errors:
|
||||
result['warnings'] = detach_errors
|
||||
return Response(result)
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
@ -406,16 +437,36 @@ def iam_user_enable_view(request, pk):
|
||||
|
||||
svc = IAMService(ak, sk)
|
||||
try:
|
||||
# 1. 恢复控制台 + API 密钥
|
||||
svc.enable_user(user.username)
|
||||
|
||||
# 2. 重新附加停用时保存的策略
|
||||
restored_count = 0
|
||||
restore_errors = []
|
||||
saved_policies = user.saved_policies_on_disable or []
|
||||
for p in saved_policies:
|
||||
try:
|
||||
svc.attach_user_policy(user.username, p["name"], p["type"])
|
||||
restored_count += 1
|
||||
except VolcengineAPIError as restore_err:
|
||||
restore_errors.append(f"{p['name']}: {restore_err}")
|
||||
|
||||
user.status = IAMUser.Status.ACTIVE
|
||||
user.save(update_fields=['status'])
|
||||
user.saved_policies_on_disable = []
|
||||
user.save(update_fields=['status', 'saved_policies_on_disable'])
|
||||
|
||||
error_info = f",恢复失败: {restore_errors}" if restore_errors else ""
|
||||
AlertRecord.objects.create(
|
||||
iam_user=user,
|
||||
alert_type=AlertRecord.AlertType.MANUAL,
|
||||
title=f"手动恢复子账号 {user.username}",
|
||||
content=f"操作人: {request.user.username}",
|
||||
content=f"操作人: {request.user.username},已恢复 {restored_count} 个权限策略{error_info}",
|
||||
)
|
||||
return Response({'message': f'用户 {user.username} 已恢复'})
|
||||
msg = f'用户 {user.username} 已恢复,{restored_count} 个权限策略已恢复'
|
||||
result = {'message': msg}
|
||||
if restore_errors:
|
||||
result['warnings'] = restore_errors
|
||||
return Response(result)
|
||||
except VolcengineAPIError as e:
|
||||
return Response({'error': 'api_error', 'message': str(e)},
|
||||
status=status.HTTP_502_BAD_GATEWAY)
|
||||
@ -546,15 +597,14 @@ def iam_user_project_add_view(request, pk):
|
||||
return Response({'error': 'duplicate', 'message': f'项目 {d["project_name"]} 已关联'},
|
||||
status=status.HTTP_409_CONFLICT)
|
||||
|
||||
# 自动在项目范围内授权默认策略
|
||||
# 在项目范围内授权前端指定的策略(如果传入了)
|
||||
account, ak, sk = _get_volc_account(user.volc_account_id)
|
||||
attached = []
|
||||
auth_errors = []
|
||||
if ak:
|
||||
policies_to_attach = d.get('policies', [])
|
||||
if ak and policies_to_attach:
|
||||
svc = IAMService(ak, sk)
|
||||
config = GlobalConfig.get_solo()
|
||||
policies = config.default_project_policies or ['ArkFullAccess', 'TOSFullAccess']
|
||||
for policy_name in policies:
|
||||
for policy_name in policies_to_attach:
|
||||
try:
|
||||
svc.attach_policy_in_project(user.username, policy_name,
|
||||
d['project_name'])
|
||||
|
||||
@ -165,11 +165,21 @@
|
||||
:loading="volcProjectsLoading">
|
||||
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleAddProject" :disabled="!projectToAdd">添加</el-button>
|
||||
<el-button @click="loadVolcProjects" :loading="volcProjectsLoading" text>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="projectToAdd" style="margin-bottom:12px;">
|
||||
<div style="margin-bottom:4px; font-size:13px; color:#606266;">授权策略(可多选,不选则仅加入监测不授权):</div>
|
||||
<el-checkbox-group v-model="projectPoliciesToAttach">
|
||||
<el-checkbox label="ArkFullAccess">方舟/Seedance 完整权限</el-checkbox>
|
||||
<el-checkbox label="ArkReadOnlyAccess">方舟只读</el-checkbox>
|
||||
<el-checkbox label="TOSFullAccess">对象存储完整权限</el-checkbox>
|
||||
<el-checkbox label="TOSReadOnlyAccess">对象存储只读</el-checkbox>
|
||||
<el-checkbox label="AccessKeySelfManageAccess">自管理密钥</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-button type="primary" @click="handleAddProject" style="margin-top:8px;">确认添加</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-button size="small" @click="handleToggleAll(true)">全部开启监测</el-button>
|
||||
<el-button size="small" @click="handleToggleAll(false)">全部关闭监测</el-button>
|
||||
@ -404,6 +414,7 @@ const projectsUser = ref(null)
|
||||
const userProjects = ref([])
|
||||
const projectsDialogLoading = ref(false)
|
||||
const projectToAdd = ref('')
|
||||
const projectPoliciesToAttach = ref([])
|
||||
const volcProjects = ref([])
|
||||
const volcProjectsLoading = ref(false)
|
||||
|
||||
@ -498,11 +509,16 @@ async function loadUserProjects(userId) {
|
||||
async function handleAddProject() {
|
||||
if (!projectToAdd.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
|
||||
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
|
||||
project_name: projectToAdd.value,
|
||||
policies: projectPoliciesToAttach.value,
|
||||
})
|
||||
ElMessage.success('已添加')
|
||||
const policyMsg = data.attached_policies?.length
|
||||
? `,已授权 ${data.attached_policies.length} 个策略`
|
||||
: ''
|
||||
ElMessage.success(`已添加${policyMsg}`)
|
||||
projectToAdd.value = ''
|
||||
projectPoliciesToAttach.value = []
|
||||
await loadUserProjects(projectsUser.value.id)
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
|
||||
@ -624,7 +624,7 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
|
||||
**关键设计:**
|
||||
- **多项目聚合**:一个子账号可关联多个火山项目,每个项目有独立监测开关。消费 = 所有开启监测的项目消费之和
|
||||
- **项目即权限**:添加项目时自动调用 `AttachPolicyInProject` 在项目范围内授权(默认 ArkFullAccess + TOSFullAccess,可在系统设置中配置),移除项目时自动回收权限。子账号只能操作被授权的项目,碰不到其他人的资源
|
||||
- **项目即权限**:添加项目时自动调用 `AttachPolicyInProject` 在项目范围内授权,移除项目时自动回收权限。子账号只能操作被授权的项目,碰不到其他人的资源。**添加项目时授权哪些策略由管理员在弹窗中手动选择**(从下拉列表选,支持多选,默认不预选任何策略),避免系统自动附加不需要的权限
|
||||
- **项目明细可查**:前端可展开查看每个项目的独立消费,便于分析哪个团队/项目花得多
|
||||
- **非月度制**:额度不按月重置,是一次性划拨,用完再充
|
||||
- **可追加可扣减**:主账号可随时追加额度(+5万)或扣减额度(-3万),支持灵活调整
|
||||
@ -640,18 +640,21 @@ balance = billing_client.call("QueryBalanceAcct")
|
||||
|
||||
### 8.1 完全停用子账号(保留账号,可恢复)
|
||||
|
||||
需要**同时**执行两个操作才能完全停用:
|
||||
需要**同时**执行三个操作才能完全停用:
|
||||
|
||||
> **重要发现(2026-03-20 实测验证)**:仅停用控制台登录 + 停用 API 密钥不够。如果子账号已经登录了火山控制台(浏览器会话未过期),他仍然可以继续操作(如在体验中心生成视频)。**必须同时移除所有权限策略**,这样即使会话未过期,刷新页面后任何操作都会返回"权限不足"。
|
||||
|
||||
```python
|
||||
def get_user_access_keys(iam_client, username: str) -> list:
|
||||
"""获取用户的所有 AccessKey ID"""
|
||||
result = iam_client.call("ListAccessKeys", {"UserName": username})
|
||||
keys = result.get("Result", {}).get("AccessKeyMetadata", [])
|
||||
return [k["AccessKeyId"] for k in keys]
|
||||
|
||||
|
||||
def disable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"""完全停用子账号(保留账号,可一键恢复)"""
|
||||
"""完全停用子账号(保留账号,可一键恢复)
|
||||
|
||||
三步停用:
|
||||
1. 停用控制台登录(阻止新登录)
|
||||
2. 停用所有 API 密钥(阻止 API 调用)
|
||||
3. 移除所有权限策略(已登录的会话也无法操作)
|
||||
|
||||
移除的策略列表保存到本地数据库,恢复时自动加回。
|
||||
"""
|
||||
|
||||
# 0. 如果未传入 access_key_ids,自动查询
|
||||
if access_key_ids is None:
|
||||
@ -671,14 +674,38 @@ def disable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"UserName": username
|
||||
})
|
||||
|
||||
print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个 API 密钥)")
|
||||
# 3. 移除所有权限策略(保存到 DB 以便恢复)
|
||||
saved_policies = []
|
||||
resp = iam_client.call("ListAttachedUserPolicies", {"UserName": username})
|
||||
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
|
||||
for p in policies:
|
||||
policy_name = p["PolicyName"]
|
||||
policy_type = p["PolicyType"]
|
||||
iam_client.call("DetachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": policy_name,
|
||||
"PolicyType": policy_type,
|
||||
})
|
||||
saved_policies.append({"name": policy_name, "type": policy_type})
|
||||
|
||||
# saved_policies 存入本地 DB 的 IAMUser.saved_policies_on_disable 字段(JSONField)
|
||||
# 恢复时从此字段读取并重新附加
|
||||
|
||||
print(f"用户 {username} 已完全停用(控制台 + {len(access_key_ids)} 个密钥 + {len(saved_policies)} 个策略已移除)")
|
||||
```
|
||||
|
||||
### 8.2 一键恢复子账号
|
||||
|
||||
```python
|
||||
def enable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"""一键恢复子账号"""
|
||||
def enable_sub_user(iam_client, username: str, access_key_ids: list = None,
|
||||
saved_policies: list = None):
|
||||
"""一键恢复子账号
|
||||
|
||||
三步恢复(与停用操作完全对称):
|
||||
1. 恢复控制台登录
|
||||
2. 恢复所有 API 密钥
|
||||
3. 重新附加停用时保存的权限策略
|
||||
"""
|
||||
|
||||
# 0. 如果未传入 access_key_ids,自动查询
|
||||
if access_key_ids is None:
|
||||
@ -698,7 +725,18 @@ def enable_sub_user(iam_client, username: str, access_key_ids: list = None):
|
||||
"UserName": username
|
||||
})
|
||||
|
||||
print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个 API 密钥)")
|
||||
# 3. 重新附加停用时保存的策略
|
||||
if saved_policies:
|
||||
for p in saved_policies:
|
||||
iam_client.call("AttachUserPolicy", {
|
||||
"UserName": username,
|
||||
"PolicyName": p["name"],
|
||||
"PolicyType": p["type"],
|
||||
})
|
||||
|
||||
# 恢复后清空 saved_policies_on_disable 字段
|
||||
|
||||
print(f"用户 {username} 已恢复(控制台 + {len(access_key_ids)} 个密钥 + {len(saved_policies or [])} 个策略已恢复)")
|
||||
```
|
||||
|
||||
### 8.3 停用 vs 删除的区别
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user