diff --git a/backend/apps/monitor/migrations/0006_iamuser_saved_policies_on_disable.py b/backend/apps/monitor/migrations/0006_iamuser_saved_policies_on_disable.py new file mode 100644 index 0000000..54e55a7 --- /dev/null +++ b/backend/apps/monitor/migrations/0006_iamuser_saved_policies_on_disable.py @@ -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='停用时保存的策略'), + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index c60f9c1..fd1ae68 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -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) diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index fba26cc..fb9bab5 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -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): diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 4434f21..31f1199 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -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']) diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 85ca0d4..0e888b9 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -165,11 +165,21 @@ :loading="volcProjectsLoading"> - 添加 +
+
授权策略(可多选,不选则仅加入监测不授权):
+ + 方舟/Seedance 完整权限 + 方舟只读 + 对象存储完整权限 + 对象存储只读 + 自管理密钥 + + 确认添加 +
全部开启监测 全部关闭监测 @@ -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) { diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index 65b6731..b9a0586 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -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 删除的区别