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:
seaislee1209 2026-03-20 15:01:18 +08:00
parent c4f61a4ada
commit a7e030dc57
6 changed files with 165 additions and 34 deletions

View File

@ -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='停用时保存的策略'),
),
]

View File

@ -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)

View File

@ -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):

View File

@ -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'])

View File

@ -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) {

View File

@ -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 删除的区别