From 6dd3ac5c0d57950d55c0a617aa672ca8752313d7 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 19:20:00 +0800 Subject: [PATCH 01/40] fix: handle PolicyAttachConflict when adding project policies Global policies conflict with project-level attach - treat as success since the user already has the permission globally. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 31f1199..c0bbf24 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -610,7 +610,11 @@ def iam_user_project_add_view(request, pk): d['project_name']) attached.append(policy_name) except VolcengineAPIError as e: - auth_errors.append(f"{policy_name}: {e}") + if 'PolicyAttachConflict' in str(e): + # 全局已有此策略,项目级无需重复附加,视为成功 + attached.append(policy_name) + else: + auth_errors.append(f"{policy_name}: {e}") obj.attached_policies = attached obj.save(update_fields=['attached_policies']) From c58fe56d89502263695e197310b59541232bdd1d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 19:28:14 +0800 Subject: [PATCH 02/40] feat: add project-level policy management (add/remove per project) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "授权" button on each linked project row - New dialog to select/deselect policies per project - Backend does incremental diff: only attach new, detach removed - Handle PolicyAttachConflict gracefully Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/urls.py | 1 + backend/apps/monitor/views.py | 66 ++++++++++++++++++++++++-- frontend/src/views/iam/IAMUserList.vue | 54 ++++++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 7acc38f..07a72ca 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -26,6 +26,7 @@ urlpatterns = [ path('iam-users//projects/', views.iam_user_project_list_view), path('iam-users//projects/add/', views.iam_user_project_add_view), path('iam-users//projects//', views.iam_user_project_update_view), + path('iam-users//projects//policies/', views.iam_user_project_policies_view), path('iam-users//projects//delete/', views.iam_user_project_delete_view), path('iam-users//projects/toggle-all/', views.iam_user_project_toggle_all_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index c0bbf24..b202823 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -610,11 +610,7 @@ def iam_user_project_add_view(request, pk): d['project_name']) attached.append(policy_name) except VolcengineAPIError as e: - if 'PolicyAttachConflict' in str(e): - # 全局已有此策略,项目级无需重复附加,视为成功 - attached.append(policy_name) - else: - auth_errors.append(f"{policy_name}: {e}") + auth_errors.append(f"{policy_name}: {e}") obj.attached_policies = attached obj.save(update_fields=['attached_policies']) @@ -652,6 +648,66 @@ def iam_user_project_update_view(request, pk, pid): return Response(IAMUserProjectSerializer(project).data) +@api_view(['PUT']) +def iam_user_project_policies_view(request, pk, pid): + """更新项目级授权策略(增量对比:移除旧的、添加新的)""" + try: + project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk) + user = project.iam_user + except IAMUserProject.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + new_policies = request.data.get('policies', []) + old_policies = project.attached_policies or [] + + account, ak, sk = _get_volc_account(user.volc_account_id) + if not ak: + return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST) + + svc = IAMService(ak, sk) + attached = [] + detached = [] + errors = [] + + # Remove policies that were removed + to_remove = [p for p in old_policies if p not in new_policies] + for policy_name in to_remove: + try: + svc.detach_policy_in_project(user.username, policy_name, project.project_name) + detached.append(policy_name) + except VolcengineAPIError as e: + errors.append(f"移除 {policy_name}: {e}") + + # Add policies that are new + to_add = [p for p in new_policies if p not in old_policies] + for policy_name in to_add: + try: + svc.attach_policy_in_project(user.username, policy_name, project.project_name) + attached.append(policy_name) + except VolcengineAPIError as e: + if 'PolicyAttachConflict' in str(e): + attached.append(policy_name) + else: + errors.append(f"添加 {policy_name}: {e}") + + project.attached_policies = new_policies + project.save(update_fields=['attached_policies']) + + AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"更新项目 {project.project_name} 授权策略", + content=f"操作人: {request.user.username},添加: {attached},移除: {detached}" + + (f",失败: {errors}" if errors else ""), + ) + + result = {'message': f'已更新,添加 {len(attached)} 个、移除 {len(detached)} 个策略', + 'project': IAMUserProjectSerializer(project).data} + if errors: + result['warnings'] = errors + return Response(result) + + @api_view(['DELETE']) def iam_user_project_delete_view(request, pk, pid): """移除关联项目:回收权限 + 移出监测""" diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 0e888b9..64e3414 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -204,14 +204,37 @@ - + + + +

+ 子账号 {{ projectsUser?.username }} 在此项目下的权限: +

+ +
+ 方舟/Seedance 完整权限 + 方舟只读 + 对象存储完整权限 + 对象存储只读 + 自管理密钥 +
+
+ +
+ @@ -538,6 +561,35 @@ async function handleToggleProject(row, val) { } } +// === Project Policies === +const projectPolicyVisible = ref(false) +const projectPolicyProject = ref(null) +const projectPolicySelected = ref([]) +const projectPolicySaving = ref(false) + +function openProjectPolicies(row) { + projectPolicyProject.value = row + projectPolicySelected.value = [...(row.attached_policies || [])] + projectPolicyVisible.value = true +} + +async function handleSaveProjectPolicies() { + projectPolicySaving.value = true + try { + const { data } = await api.put( + `/api/v1/iam-users/${projectsUser.value.id}/projects/${projectPolicyProject.value.id}/policies/`, + { policies: projectPolicySelected.value } + ) + ElMessage.success(data.message || '已更新') + projectPolicyVisible.value = false + await loadUserProjects(projectsUser.value.id) + } catch (e) { + ElMessage.error(e.response?.data?.message || '更新失败') + } finally { + projectPolicySaving.value = false + } +} + async function handleRemoveProject(row) { await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' }) try { From 314612f4547ee88717c63aed5acd113d0be7a8df Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 19:52:34 +0800 Subject: [PATCH 03/40] feat: add ArkExperienceAccess to policy options Required for sub-accounts to manage API Keys in Ark console. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/views/iam/IAMUserList.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 64e3414..9edaf90 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -173,6 +173,7 @@
授权策略(可多选,不选则仅加入监测不授权):
方舟/Seedance 完整权限 + 方舟体验权限(API Key 管理需要) 方舟只读 对象存储完整权限 对象存储只读 @@ -223,6 +224,7 @@
方舟/Seedance 完整权限 + 方舟体验权限(API Key 管理需要) 方舟只读 对象存储完整权限 对象存储只读 @@ -262,6 +264,7 @@ + From 8e564ed6401b1fccc6c3eb3efca9693b1eb61c8e Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 20:36:07 +0800 Subject: [PATCH 04/40] docs: major report update - AirGate as sole entry point for sub-accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key changes: - Architecture upgraded: sub-accounts do NOT log into Volcengine console - Documented Ark API Key management via POST+JSON (verified working) - Added chapter 12 (Ark API Key mgmt) and 13 (实测发现 with decisions) - Fixed Step 1 code example to NOT create console login - Updated core requirements table, architecture diagram, limitations - All findings verified through actual API testing on 2026-03-20 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/utils/volcengine_client.py | 5 +- 火山引擎IAM子账号管控工具_深度研究报告.md | 238 ++++++++++++++++++---- 2 files changed, 200 insertions(+), 43 deletions(-) diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py index fe52120..fbfdadf 100644 --- a/backend/utils/volcengine_client.py +++ b/backend/utils/volcengine_client.py @@ -46,7 +46,7 @@ class VolcengineClient: def _hash_sha256(self, content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() - def call(self, action: str, params: dict = None, body: str = "") -> dict: + def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict: params = params or {} now = datetime.datetime.now(datetime.timezone.utc) x_date = now.strftime("%Y%m%dT%H%M%SZ") @@ -91,6 +91,9 @@ class VolcengineClient: ), } + if extra_headers: + headers.update(extra_headers) + url = f"https://{self.host}/?{query_string}" try: r = requests.get(url, headers=headers, timeout=30) diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index b9a0586..2ac24e1 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -1,7 +1,7 @@ # 火山引擎 IAM 子账号管控工具 -- 深度研究报告 -> 研究日期:2026-03-19 -> 目标:通过火山引擎 Open API,实现对 IAM 子账号的全面管控,包括权限隔离、消费监控、告警、自动停用等功能。 +> 研究日期:2026-03-19(最后更新:2026-03-20) +> 目标:通过火山引擎 Open API,构建 IAM 子账号的完整管控平台。子账号**不登录火山控制台**,所有操作(API Key 管理、消费查询等)均通过 AirGate 完成,实现权限隔离、消费监控、告警、自动停用等功能。 --- @@ -18,8 +18,10 @@ 9. [项目管理与资源隔离](#9-项目管理与资源隔离) 10. [SDK 与工具链](#10-sdk-与工具链) 11. [可执行实施方案](#11-可执行实施方案) -12. [限制与注意事项](#12-限制与注意事项) -13. [参考文档](#13-参考文档) +12. [方舟 API Key 管理](#12-方舟-api-key-管理) +13. [实测发现与架构决策](#13-实测发现与架构决策) +14. [限制与注意事项](#14-限制与注意事项) +15. [参考文档](#15-参考文档) --- @@ -29,40 +31,54 @@ | 需求 | 实现方式 | 可行性 | |------|----------|--------| -| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** | -| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** | +| 子账号不能看到主账号信息 | **子账号不登录火山控制台**,只登录 AirGate | **完全可行** | +| 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess,全局无权限 | **完全可行** | | 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** | -| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** | +| 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** | +| 子账号能管理自己的 API Key | AirGate 调用方舟 API(POST + JSON body)代为管理 | **完全可行**(已验证) | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** | -| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** | -| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** | +| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** | +| 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** | ### 1.2 架构图 ``` -┌──────────────────────────────────────────────────────┐ -│ 管控工具 (后端服务) │ -│ │ -│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ -│ │ IAM管理 │ │ 消费监控 │ │ 告警引擎 │ │ -│ │ 模块 │ │ 模块 │ │ 模块 │ │ -│ └────┬─────┘ └────┬─────┘ └────┬───────┘ │ -│ │ │ │ │ -│ ┌────▼─────────────▼─────────────▼───────┐ │ -│ │ 火山引擎 Open API 调用层 │ │ -│ │ (HMAC-SHA256 签名认证) │ │ -│ └────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│ AirGate(子账号的唯一操作入口) │ +│ │ +│ 管理员界面 子账号界面 │ +│ ┌───────────────┐ ┌──────────────┐ │ +│ │ 子账号管理 │ │ 我的 API Key │ │ +│ │ 消费监控/告警 │ │ 我的消费 │ │ +│ │ 额度划拨 │ │ 我的项目 │ │ +│ │ 权限配置 │ └──────────────┘ │ +│ │ 系统管理 │ │ +│ └───────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 火山引擎 Open API 调用层 │ │ +│ │ IAM API | Billing API | 方舟 API (Ark) │ │ +│ │ (HMAC-SHA256 签名认证) │ │ +│ └────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ │ │ │ - ┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐ - │ IAM API │ │Billing API│ │CloudMonitor│ + ┌────▼────┐ ┌─────▼─────┐ ┌────▼──────┐ + │ IAM API │ │Billing API│ │ 方舟 API │ │iam.vol..│ │billing.vol│ │open.vol.. │ - └─────────┘ └───────────┘ └────────────┘ + └─────────┘ └───────────┘ └───────────┘ ``` -### 1.3 关键发现 +### 1.3 关键发现与架构决策 -> **重要**:火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**或**标签(Tag)**维度来实现,由主账号通过 Billing API 查询后聚合展示。 +> **重要发现(2026-03-20 实测验证)**: +> +> 1. **火山控制台的权限隔离不彻底**:给子账号全局 `ArkReadOnlyAccess` 后,子账号在方舟控制台能看到**所有项目**的 API Key,包括其他子账号和主账号的资源。项目级授权能控制"能不能操作",但控制台页面渲染依赖全局只读权限。 +> +> 2. **方舟 API Key 管理页面需要 `ArkExperienceAccess` 全局权限**:即使给了项目级 `ArkFullAccess`,不加全局体验权限就无法进入 API Key 管理页面。而加了全局权限又会泄露其他项目信息。 +> +> 3. **架构决策**:基于以上发现,AirGate 的定位从"管控工具"升级为"子账号的唯一操作入口"。子账号**不登录火山控制台**,所有操作(创建/查看/删除 API Key、查看消费等)均通过 AirGate 完成。AirGate 使用主账号的 AK/SK 调用火山 API,在应用层做项目级隔离。 +> +> 4. 火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**维度来实现。 --- @@ -868,13 +884,14 @@ iam.call("CreateUser", { "MobilePhone": "+8618000000000" }) -# 开通控制台登录 -iam.call("CreateLoginProfile", { - "UserName": "dept_a_user", - "Password": "Initial@Pass123", - "LoginAllowed": "true", - "PasswordResetRequired": "true" -}) +# 注意:不开通控制台登录(子账号通过 AirGate 操作,不登录火山控制台) +# 如果确实需要开通控制台登录(不推荐),取消以下注释: +# iam.call("CreateLoginProfile", { +# "UserName": "dept_a_user", +# "Password": "Initial@Pass123", +# "LoginAllowed": "true", +# "PasswordResetRequired": "true" +# }) # 创建 API 密钥(记录返回的 SecretAccessKey!) result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"}) @@ -1119,25 +1136,162 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛 # 项目列表 GET /api/v1/projects/ # 从火山拉取项目列表 + +# 方舟 API Key 管理(AirGate 代为操作,子账号只看到自己项目的 Key) +GET /api/v1/ark-keys/{project_name}/ # 列出指定项目下的 API Key +POST /api/v1/ark-keys/{project_name}/create/ # 在指定项目下创建 API Key +POST /api/v1/ark-keys/{key_id}/disable/ # 停用 API Key +POST /api/v1/ark-keys/{key_id}/enable/ # 启用 API Key +DELETE /api/v1/ark-keys/{key_id}/ # 删除 API Key + +# 管理员管理 +GET /api/v1/auth/admins/ # 列出所有管理员 +POST /api/v1/auth/admins/create/ # 创建管理员 +POST /api/v1/auth/admins/{id}/toggle/ # 启用/停用管理员 +POST /api/v1/auth/admins/{id}/reset-password/ # 重置管理员密码 +POST /api/v1/auth/change-password/ # 修改当前用户密码 + +# 子账号项目策略管理 +PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权策略 ``` --- -## 12. 限制与注意事项 +## 12. 方舟 API Key 管理 -### 12.1 关键限制 +### 12.1 接口发现(2026-03-20 实测验证) + +方舟 API Key 管理使用 **POST + JSON body** 方式调用,与 IAM API 的 GET + Query 方式不同。 + +| 参数 | 值 | +|------|-----| +| 端点 | `open.volcengineapi.com` | +| Service | `ark` | +| Version | `2024-01-01` | +| HTTP 方法 | **POST**(必须,GET 不传 body 会报 MissingParameter) | +| Content-Type | `application/json` | +| 签名 | HMAC-SHA256,signed_headers 包含 `content-type;host;x-content-sha256;x-date` | + +### 12.2 已验证的接口 + +```python +# ListApiKeys - 列出项目下的 API Key +POST https://open.volcengineapi.com/?Action=ListApiKeys&Version=2024-01-01 +Body: {"ProjectName": "zyc_test", "PageSize": 10} + +# 返回结果包含: +# - TotalCount: 总数 +# - Items[].Id: Key ID +# - Items[].Key: "fedd****a052"(脱敏) +# - Items[].ProjectName: 所属项目 +# - Items[].Name: Key 名称 +# - Items[].Status: Active/Inactive +# - Items[].Tags[]: 包含创建者信息(如 IAMUser/76804896/zyc) +``` + +### 12.3 待验证的接口 + +以下接口需要实际调用验证参数: + +```python +# CreateApiKey - 创建 API Key +POST ?Action=CreateApiKey&Version=2024-01-01 +Body: {"ProjectName": "xxx", "Name": "key-name", "ResourceInstances": [...]} + +# DeleteApiKey - 删除 API Key +POST ?Action=DeleteApiKey&Version=2024-01-01 +Body: {"ApiKeyId": "xxx"} + +# UpdateApiKey - 更新 API Key(启用/停用) +POST ?Action=UpdateApiKey&Version=2024-01-01 +Body: {"ApiKeyId": "xxx", "Status": "Active/Inactive"} +``` + +### 12.4 AirGate 集成方案 + +AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: + +``` +子账号登录 AirGate + → 看到自己关联的项目 + → 选择项目 → 查看该项目下的 API Key(只看自己项目的) + → 创建新 Key / 停用 Key / 删除 Key + → AirGate 后端用主账号 AK/SK 调用方舟 API 执行操作 + → 项目级隔离由 AirGate 应用层控制(查询时只传子账号关联的项目名) +``` + +**关键**:子账号不需要火山控制台的任何权限来管理 API Key,因为所有操作都由 AirGate 使用主账号身份代为执行。 + +--- + +## 13. 实测发现与架构决策 + +### 13.1 火山控制台权限隔离问题(2026-03-20) + +| 测试场景 | 结果 | +|----------|------| +| 项目级 `ArkFullAccess` + 无全局权限 | 无法进入方舟控制台页面,提示需要 `ArkReadOnlyAccess` | +| 全局 `ArkReadOnlyAccess` | 能进入控制台,但能看到**所有项目**的 API Key | +| 全局 `ArkExperienceAccess` | 能进入体验中心,能看到所有项目的内容 | +| 停用账号但子账号未刷新页面 | 子账号仍可在体验中心生成视频 | +| 停用账号 + 移除所有策略 | 子账号刷新页面后立即失效 | + +**结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目",必须在应用层(AirGate)控制。 + +### 13.2 最终权限方案 + +``` +子账号在火山引擎上的权限(由 AirGate 自动管理): + +全局权限:无(不需要任何全局策略) + 或仅保留 AccessKeySelfManageAccess(如果需要) + +项目级权限(通过 AttachUserPolicy + ProjectName): + ├── ArkFullAccess ← API 层面有完整方舟操作权限 + └── TOSFullAccess ← API 层面有 TOS 操作权限 + +火山控制台登录:不开通(不给密码 / 停用 LoginProfile) +``` + +子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成: + +| 操作 | 在哪里做 | +|------|----------| +| 创建/查看/删除 API Key | AirGate(代调方舟 API) | +| 查看消费 | AirGate(代调 Billing API) | +| 管理项目 | AirGate(管理员操作) | +| 使用 Seedance 2.0 | 直接用 API Key 调用(不需要控制台) | + +### 13.3 停用/恢复增强方案(2026-03-20 实测验证) + +停用操作执行三步(确保即使子账号有活跃浏览器会话也立即失效): + +1. **停用控制台登录**:`UpdateLoginProfile(LoginAllowed=false)` — 阻止新登录 +2. **停用所有 API 密钥**:`UpdateAccessKey(Status=inactive)` — 阻止 API 调用 +3. **移除所有权限策略**:遍历 `ListAttachedUserPolicies` 结果,逐个 `DetachUserPolicy` — 已登录的会话刷新后也无法操作 + +移除的策略列表保存到数据库 `saved_policies_on_disable` 字段(JSONField),恢复时自动附加回来。 + +--- + +## 14. 限制与注意事项 + +### 14.1 关键限制 | 限制项 | 说明 | |--------|------| | IAM 子账号无独立计费 | 所有费用归主账号,通过多项目聚合追踪(子账号关联 N 个项目,消费=开启监测的项目之和) | | Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 | -| 每用户最多 2 个 API 密钥 | 无法创建更多 | -| SecretKey 仅返回一次 | 创建后立即保存 | +| 每用户最多 2 个 IAM AccessKey | IAM 级别的 AK/SK 最多 2 对(方舟 API Key 无此限制) | +| IAM SecretKey 仅返回一次 | 创建后立即保存 | | Billing API QPS 限制 5 | 批量查询需注意限流 | -| Ark 推理限额无公开 API | 目前仅支持控制台操作 | +| **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 | +| **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 | +| 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 | +| 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 | -### 12.2 安全建议 +### 14.2 安全建议 1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务 2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥 @@ -1145,7 +1299,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表 4. **显式 Deny 策略优先**,防止权限漏洞 5. **监控日志**,使用 CloudTrail 审计 API 调用 -### 12.3 消费监控的精确度问题 +### 14.3 消费监控的精确度问题 由于账单数据有 1-2 天延迟,消费监控存在滞后。AirGate 的应对策略: - **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90]) @@ -1155,7 +1309,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表 --- -## 13. 参考文档 +## 15. 参考文档 ### 官方文档 From 0ac2ef1f279ba866a06f82467dfe0bf019eff581 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 21:36:13 +0800 Subject: [PATCH 05/40] feat: add Ark API Key management (list/create/toggle/delete) - New VolcengineClient.call_json() for POST+JSON signing (Ark API) - ArkService for API Key CRUD operations - Backend views: list/create/toggle/delete ark keys per project - Frontend: ArkKeysView with project selector, key table, create dialog - Created key value shown once with copy button Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/urls.py | 6 + backend/apps/monitor/views.py | 102 ++++++++++++ backend/utils/ark_service.py | 42 +++++ backend/utils/volcengine_client.py | 45 +++-- frontend/src/layouts/MainLayout.vue | 6 +- frontend/src/router/index.js | 1 + frontend/src/views/ark/ArkKeysView.vue | 217 +++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 13 deletions(-) create mode 100644 backend/utils/ark_service.py create mode 100644 frontend/src/views/ark/ArkKeysView.vue diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 07a72ca..ad3443e 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -47,4 +47,10 @@ urlpatterns = [ # Projects path('projects/', views.project_list_view), + + # Ark API Key management + path('ark-keys//', views.ark_key_list_view), + path('ark-keys//create/', views.ark_key_create_view), + path('ark-keys//toggle/', views.ark_key_toggle_view), + path('ark-keys//delete/', views.ark_key_delete_view), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index b202823..a0f07c5 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -12,6 +12,7 @@ from rest_framework.response import Response from utils.crypto import encrypt, decrypt, make_hint from utils.iam_service import IAMService, ProjectService from utils.billing_service import BillingService +from utils.ark_service import ArkService from utils.volcengine_client import VolcengineAPIError from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation @@ -921,3 +922,104 @@ def project_list_view(request): except VolcengineAPIError as e: return Response({'error': 'api_error', 'message': str(e)}, status=status.HTTP_502_BAD_GATEWAY) + + +# ==================== Ark API Key Management ==================== + +def _get_ark_service(): + """获取 ArkService 实例""" + account, ak, sk = _get_volc_account() + if not ak: + return None, None + return ArkService(ak, sk), account + + +@api_view(['GET']) +def ark_key_list_view(request, project_name): + """列出项目下的方舟 API Key""" + svc, _ = _get_ark_service() + if not svc: + return Response({'error': 'no_account', 'message': '请先配置火山主账号'}, + status=status.HTTP_400_BAD_REQUEST) + try: + resp = svc.list_api_keys(project_name) + items = resp.get("Result", {}).get("Items", []) + return Response({ + 'total': resp.get("Result", {}).get("TotalCount", 0), + 'keys': items, + }) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +@api_view(['POST']) +def ark_key_create_view(request, project_name): + """在项目下创建方舟 API Key""" + name = request.data.get('name', '') + if not name: + return Response({'error': 'missing_name', 'message': '请输入 Key 名称'}, + status=status.HTTP_400_BAD_REQUEST) + + svc, _ = _get_ark_service() + if not svc: + return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = svc.create_api_key(project_name, name) + key_data = resp.get("Result", {}) + AlertRecord.objects.create( + alert_type=AlertRecord.AlertType.MANUAL, + title=f"创建方舟 API Key: {name}", + content=f"操作人: {request.user.username},项目: {project_name}", + ) + return Response({ + 'message': f'API Key "{name}" 创建成功', + 'key': key_data, + }, status=status.HTTP_201_CREATED) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +@api_view(['POST']) +def ark_key_toggle_view(request, key_id): + """启用/停用方舟 API Key""" + new_status = request.data.get('status', '') + if new_status not in ('Active', 'Inactive'): + return Response({'error': 'invalid_status', 'message': 'status 必须是 Active 或 Inactive'}, + status=status.HTTP_400_BAD_REQUEST) + + svc, _ = _get_ark_service() + if not svc: + return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) + try: + svc.update_api_key_status(key_id, new_status) + action = '启用' if new_status == 'Active' else '停用' + AlertRecord.objects.create( + alert_type=AlertRecord.AlertType.MANUAL, + title=f"{action}方舟 API Key (ID: {key_id})", + content=f"操作人: {request.user.username}", + ) + return Response({'message': f'API Key 已{action}'}) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) + + +@api_view(['DELETE']) +def ark_key_delete_view(request, key_id): + """删除方舟 API Key""" + svc, _ = _get_ark_service() + if not svc: + return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) + try: + svc.delete_api_key(key_id) + AlertRecord.objects.create( + alert_type=AlertRecord.AlertType.MANUAL, + title=f"删除方舟 API Key (ID: {key_id})", + content=f"操作人: {request.user.username}", + ) + return Response({'message': 'API Key 已删除'}) + except VolcengineAPIError as e: + return Response({'error': 'api_error', 'message': str(e)}, + status=status.HTTP_502_BAD_GATEWAY) diff --git a/backend/utils/ark_service.py b/backend/utils/ark_service.py new file mode 100644 index 0000000..2283394 --- /dev/null +++ b/backend/utils/ark_service.py @@ -0,0 +1,42 @@ +"""方舟(Ark)API Key 管理服务""" + +import logging +from .volcengine_client import VolcengineClient, VolcengineAPIError, get_ark_client + +logger = logging.getLogger(__name__) + + +class ArkService: + """方舟 API Key 管理""" + + def __init__(self, ak: str, sk: str): + self.client = get_ark_client(ak, sk) + + def list_api_keys(self, project_name: str, page_size: int = 100, page_number: int = 1) -> dict: + """列出项目下的 API Key""" + return self.client.call_json("ListApiKeys", { + "ProjectName": project_name, + "PageSize": page_size, + "PageNumber": page_number, + }) + + def create_api_key(self, project_name: str, name: str, resource_type: str = "all") -> dict: + """在项目下创建 API Key""" + return self.client.call_json("CreateApiKey", { + "ProjectName": project_name, + "Name": name, + "ResourceInstances": [{"ResourceId": "*", "ResourceType": resource_type}], + }) + + def delete_api_key(self, api_key_id: int) -> dict: + """删除 API Key""" + return self.client.call_json("DeleteApiKey", { + "Id": api_key_id, + }) + + def update_api_key_status(self, api_key_id: int, status: str) -> dict: + """启用/停用 API Key (status: Active / Inactive)""" + return self.client.call_json("UpdateApiKey", { + "Id": api_key_id, + "Status": status, + }) diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py index fbfdadf..2a6e6ea 100644 --- a/backend/utils/volcengine_client.py +++ b/backend/utils/volcengine_client.py @@ -46,25 +46,25 @@ class VolcengineClient: def _hash_sha256(self, content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() - def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict: - params = params or {} + def _sign_and_call(self, action: str, method: str, content_type: str, + query_params: dict, body_bytes: bytes) -> dict: + """统一签名并调用""" now = datetime.datetime.now(datetime.timezone.utc) x_date = now.strftime("%Y%m%dT%H%M%SZ") short_date = x_date[:8] - x_content_sha256 = self._hash_sha256(body) - all_params = {"Action": action, "Version": self.version, **params} + x_content_sha256 = hashlib.sha256(body_bytes).hexdigest() + query_string = self._norm_query(query_params) signed_headers_str = "content-type;host;x-content-sha256;x-date" canonical_headers = ( - f"content-type:application/x-www-form-urlencoded\n" + f"content-type:{content_type}\n" f"host:{self.host}\n" f"x-content-sha256:{x_content_sha256}\n" f"x-date:{x_date}" ) - query_string = self._norm_query(all_params) canonical_request = "\n".join([ - "GET", "/", query_string, + method, "/", query_string, canonical_headers, "", signed_headers_str, x_content_sha256 ]) @@ -84,19 +84,19 @@ class VolcengineClient: "Host": self.host, "X-Date": x_date, "X-Content-Sha256": x_content_sha256, - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": content_type, "Authorization": ( f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, " f"SignedHeaders={signed_headers_str}, Signature={signature}" ), } - if extra_headers: - headers.update(extra_headers) - url = f"https://{self.host}/?{query_string}" try: - r = requests.get(url, headers=headers, timeout=30) + if method == "GET": + r = requests.get(url, headers=headers, timeout=30) + else: + r = requests.post(url, headers=headers, data=body_bytes, timeout=30) resp = r.json() except Exception as e: raise VolcengineAPIError(action, "NetworkError", str(e)) @@ -108,6 +108,21 @@ class VolcengineClient: ) return resp + def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict: + """GET 方式调用(IAM / Billing 等传统接口)""" + params = params or {} + all_params = {"Action": action, "Version": self.version, **params} + return self._sign_and_call(action, "GET", "application/x-www-form-urlencoded", + all_params, body.encode("utf-8") if body else b"") + + def call_json(self, action: str, body: dict = None) -> dict: + """POST + JSON body 方式调用(方舟 Ark 等新接口)""" + import json + query_params = {"Action": action, "Version": self.version} + body_bytes = json.dumps(body or {}).encode("utf-8") + return self._sign_and_call(action, "POST", "application/json", + query_params, body_bytes) + def get_iam_client(ak: str, sk: str) -> VolcengineClient: return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com") @@ -121,3 +136,9 @@ def get_billing_client(ak: str, sk: str) -> VolcengineClient: def get_resource_client(ak: str, sk: str) -> VolcengineClient: return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com", version="2021-08-01") + + +def get_ark_client(ak: str, sk: str) -> VolcengineClient: + """方舟 API 客户端(使用 POST + JSON body)""" + return VolcengineClient(ak, sk, "ark", "open.volcengineapi.com", + region="cn-beijing", version="2024-01-01") diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 2d7d061..8d2406a 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -12,6 +12,10 @@ 子账号管理 + + + API Key 管理 + 消费监控 @@ -25,7 +29,7 @@ 系统设置 - + 系统管理 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8896347..29c7ff2 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -16,6 +16,7 @@ const routes = [ { path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') }, { path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') }, { path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') }, + { path: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') }, { path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') }, { path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') }, ], diff --git a/frontend/src/views/ark/ArkKeysView.vue b/frontend/src/views/ark/ArkKeysView.vue new file mode 100644 index 0000000..fa3f94c --- /dev/null +++ b/frontend/src/views/ark/ArkKeysView.vue @@ -0,0 +1,217 @@ + + + From 7feb007f57a0066310a31416600524ad464966d1 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 01:25:12 +0800 Subject: [PATCH 06/40] feat: rewrite API Key management as manual entry mode - New ArkApiKey model (encrypted storage, bound to user+project) - Admin enters API Key from Volcengine console into AirGate - Sub-accounts can only view their own keys - Reveal endpoint decrypts key on demand with audit log - Updated research report: documented Ark API limitation (CreateApiKey doesn't return plaintext) and manual entry solution Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/monitor/migrations/0007_arkapikey.py | 36 +++ backend/apps/monitor/models.py | 28 ++ backend/apps/monitor/serializers.py | 24 +- backend/apps/monitor/urls.py | 11 +- backend/apps/monitor/views.py | 189 +++++++------ frontend/src/views/ark/ArkKeysView.vue | 260 ++++++++++-------- 火山引擎IAM子账号管控工具_深度研究报告.md | 113 ++++---- 7 files changed, 395 insertions(+), 266 deletions(-) create mode 100644 backend/apps/monitor/migrations/0007_arkapikey.py diff --git a/backend/apps/monitor/migrations/0007_arkapikey.py b/backend/apps/monitor/migrations/0007_arkapikey.py new file mode 100644 index 0000000..96e1400 --- /dev/null +++ b/backend/apps/monitor/migrations/0007_arkapikey.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0006_iamuser_saved_policies_on_disable'), + ] + + operations = [ + migrations.CreateModel( + name='ArkApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('project_name', models.CharField(max_length=200, verbose_name='所属项目')), + ('key_name', models.CharField(max_length=200, verbose_name='Key 名称/用途')), + ('api_key_enc', models.TextField(verbose_name='API Key(加密)')), + ('api_key_hint', models.CharField(blank=True, max_length=30, verbose_name='API Key 提示(脱敏)')), + ('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用')], default='active', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_by', models.CharField(blank=True, max_length=100, verbose_name='录入人')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ark_keys', to='monitor.iamuser')), + ], + options={ + 'verbose_name': '方舟 API Key', + 'verbose_name_plural': '方舟 API Key', + 'db_table': 'airgate_ark_api_key', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index fd1ae68..66b5411 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -116,6 +116,34 @@ class IAMUserProject(models.Model): return f"{self.project_name} ({status}) ¥{self.current_spending}" +class ArkApiKey(models.Model): + """方舟 API Key(管理员手动录入,加密存储)""" + + class Status(models.TextChoices): + ACTIVE = 'active', '启用' + DISABLED = 'disabled', '停用' + + iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='ark_keys') + project_name = models.CharField('所属项目', max_length=200) + key_name = models.CharField('Key 名称/用途', max_length=200) + api_key_enc = models.TextField('API Key(加密)') + api_key_hint = models.CharField('API Key 提示(脱敏)', max_length=30, blank=True) + status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.ACTIVE) + remark = models.TextField('备注', blank=True) + created_by = models.CharField('录入人', max_length=100, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '方舟 API Key' + verbose_name_plural = '方舟 API Key' + db_table = 'airgate_ark_api_key' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.iam_user.username}/{self.project_name}: {self.key_name} ({self.api_key_hint})" + + class QuotaAllocation(models.Model): """额度划拨记录""" iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations') diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index fb9bab5..a33e961 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey class VolcAccountSerializer(serializers.ModelSerializer): @@ -135,6 +135,28 @@ class AlertRecordSerializer(serializers.ModelSerializer): ] +class ArkApiKeySerializer(serializers.ModelSerializer): + iam_username = serializers.CharField(source='iam_user.username', read_only=True) + iam_display_name = serializers.CharField(source='iam_user.display_name', read_only=True) + + class Meta: + model = ArkApiKey + fields = [ + 'id', 'iam_user', 'iam_username', 'iam_display_name', + 'project_name', 'key_name', 'api_key_hint', 'status', + 'remark', 'created_by', 'created_at', 'updated_at', + ] + read_only_fields = ['api_key_hint', 'created_by', 'created_at', 'updated_at'] + + +class ArkApiKeyCreateSerializer(serializers.Serializer): + iam_user_id = serializers.IntegerField() + project_name = serializers.CharField(max_length=200) + key_name = serializers.CharField(max_length=200) + api_key = serializers.CharField(write_only=True) + remark = serializers.CharField(max_length=500, required=False, default='', allow_blank=True) + + class DashboardSerializer(serializers.Serializer): total_users = serializers.IntegerField() active_users = serializers.IntegerField() diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index ad3443e..80fb5b8 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -48,9 +48,10 @@ urlpatterns = [ # Projects path('projects/', views.project_list_view), - # Ark API Key management - path('ark-keys//', views.ark_key_list_view), - path('ark-keys//create/', views.ark_key_create_view), - path('ark-keys//toggle/', views.ark_key_toggle_view), - path('ark-keys//delete/', views.ark_key_delete_view), + # Ark API Key management (manual entry) + path('ark-keys/', views.ark_key_list_view), + path('ark-keys/create/', views.ark_key_create_view), + path('ark-keys//', views.ark_key_update_view), + path('ark-keys//delete/', views.ark_key_delete_view), + path('ark-keys//reveal/', views.ark_key_reveal_view), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index a0f07c5..70fd797 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -15,7 +15,7 @@ from utils.billing_service import BillingService from utils.ark_service import ArkService from utils.volcengine_client import VolcengineAPIError -from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey from .serializers import ( VolcAccountSerializer, VolcAccountCreateSerializer, IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, @@ -24,6 +24,7 @@ from .serializers import ( QuotaAllocateSerializer, QuotaAllocationSerializer, GlobalConfigSerializer, AlertRecordSerializer, + ArkApiKeySerializer, ArkApiKeyCreateSerializer, DashboardSerializer, ) @@ -924,102 +925,126 @@ def project_list_view(request): status=status.HTTP_502_BAD_GATEWAY) -# ==================== Ark API Key Management ==================== - -def _get_ark_service(): - """获取 ArkService 实例""" - account, ak, sk = _get_volc_account() - if not ak: - return None, None - return ArkService(ak, sk), account - +# ==================== Ark API Key Management (手动录入模式) ==================== @api_view(['GET']) -def ark_key_list_view(request, project_name): - """列出项目下的方舟 API Key""" - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account', 'message': '请先配置火山主账号'}, - status=status.HTTP_400_BAD_REQUEST) - try: - resp = svc.list_api_keys(project_name) - items = resp.get("Result", {}).get("Items", []) - return Response({ - 'total': resp.get("Result", {}).get("TotalCount", 0), - 'keys': items, - }) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) +def ark_key_list_view(request): + """列出 API Key(管理员看全部,子账号看自己的)""" + keys = ArkApiKey.objects.select_related('iam_user').all() + + # 按子账号筛选 + iam_user_id = request.query_params.get('iam_user_id') + if iam_user_id: + keys = keys.filter(iam_user_id=iam_user_id) + + # 按项目筛选 + project_name = request.query_params.get('project_name') + if project_name: + keys = keys.filter(project_name=project_name) + + return Response(ArkApiKeySerializer(keys, many=True).data) @api_view(['POST']) -def ark_key_create_view(request, project_name): - """在项目下创建方舟 API Key""" - name = request.data.get('name', '') - if not name: - return Response({'error': 'missing_name', 'message': '请输入 Key 名称'}, - status=status.HTTP_400_BAD_REQUEST) +def ark_key_create_view(request): + """录入 API Key(管理员操作)""" + serializer = ArkApiKeyCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + d = serializer.validated_data - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) try: - resp = svc.create_api_key(project_name, name) - key_data = resp.get("Result", {}) - AlertRecord.objects.create( - alert_type=AlertRecord.AlertType.MANUAL, - title=f"创建方舟 API Key: {name}", - content=f"操作人: {request.user.username},项目: {project_name}", - ) - return Response({ - 'message': f'API Key "{name}" 创建成功', - 'key': key_data, - }, status=status.HTTP_201_CREATED) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + iam_user = IAMUser.objects.get(pk=d['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found', 'message': '子账号不存在'}, + status=status.HTTP_404_NOT_FOUND) + + api_key_raw = d['api_key'] + obj = ArkApiKey.objects.create( + iam_user=iam_user, + project_name=d['project_name'], + key_name=d['key_name'], + api_key_enc=encrypt(api_key_raw), + api_key_hint=make_hint(api_key_raw), + remark=d.get('remark', ''), + created_by=request.user.username, + ) + + AlertRecord.objects.create( + iam_user=iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"录入 API Key: {d['key_name']}", + content=f"操作人: {request.user.username},项目: {d['project_name']}", + ) + + return Response({ + 'message': f'API Key "{d["key_name"]}" 录入成功', + 'key': ArkApiKeySerializer(obj).data, + }, status=status.HTTP_201_CREATED) -@api_view(['POST']) -def ark_key_toggle_view(request, key_id): - """启用/停用方舟 API Key""" - new_status = request.data.get('status', '') - if new_status not in ('Active', 'Inactive'): - return Response({'error': 'invalid_status', 'message': 'status 必须是 Active 或 Inactive'}, - status=status.HTTP_400_BAD_REQUEST) - - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) +@api_view(['PUT']) +def ark_key_update_view(request, pk): + """更新 API Key(启用/停用/改备注)""" try: - svc.update_api_key_status(key_id, new_status) - action = '启用' if new_status == 'Active' else '停用' + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + new_status = request.data.get('status') + if new_status and new_status in ('active', 'disabled'): + old_status = obj.status + obj.status = new_status + action = '启用' if new_status == 'active' else '停用' AlertRecord.objects.create( + iam_user=obj.iam_user, alert_type=AlertRecord.AlertType.MANUAL, - title=f"{action}方舟 API Key (ID: {key_id})", + title=f"{action} API Key: {obj.key_name}", content=f"操作人: {request.user.username}", ) - return Response({'message': f'API Key 已{action}'}) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + + remark = request.data.get('remark') + if remark is not None: + obj.remark = remark + + obj.save() + return Response(ArkApiKeySerializer(obj).data) @api_view(['DELETE']) -def ark_key_delete_view(request, key_id): - """删除方舟 API Key""" - svc, _ = _get_ark_service() - if not svc: - return Response({'error': 'no_account'}, status=status.HTTP_400_BAD_REQUEST) +def ark_key_delete_view(request, pk): + """删除 API Key""" try: - svc.delete_api_key(key_id) - AlertRecord.objects.create( - alert_type=AlertRecord.AlertType.MANUAL, - title=f"删除方舟 API Key (ID: {key_id})", - content=f"操作人: {request.user.username}", - ) - return Response({'message': 'API Key 已删除'}) - except VolcengineAPIError as e: - return Response({'error': 'api_error', 'message': str(e)}, - status=status.HTTP_502_BAD_GATEWAY) + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + AlertRecord.objects.create( + iam_user=obj.iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"删除 API Key: {obj.key_name}", + content=f"操作人: {request.user.username},项目: {obj.project_name}", + ) + obj.delete() + return Response({'message': 'API Key 已删除'}) + + +@api_view(['GET']) +def ark_key_reveal_view(request, pk): + """查看完整 API Key(解密展示)""" + try: + obj = ArkApiKey.objects.get(pk=pk) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + AlertRecord.objects.create( + iam_user=obj.iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"查看 API Key 明文: {obj.key_name}", + content=f"操作人: {request.user.username}", + ) + + return Response({ + 'api_key': decrypt(obj.api_key_enc), + 'key_name': obj.key_name, + 'project_name': obj.project_name, + }) diff --git a/frontend/src/views/ark/ArkKeysView.vue b/frontend/src/views/ark/ArkKeysView.vue index fa3f94c..34274e5 100644 --- a/frontend/src/views/ark/ArkKeysView.vue +++ b/frontend/src/views/ark/ArkKeysView.vue @@ -1,217 +1,239 @@ diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index 2ac24e1..11d5267 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -35,7 +35,7 @@ | 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess,全局无权限 | **完全可行** | | 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** | | 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** | -| 子账号能管理自己的 API Key | AirGate 调用方舟 API(POST + JSON body)代为管理 | **完全可行**(已验证) | +| 子账号能查看自己的 API Key | 管理员在火山控制台创建 Key 后录入 AirGate,子账号登录 AirGate 查看 | **完全可行** | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** | | 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** | | 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** | @@ -1137,12 +1137,12 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛 # 项目列表 GET /api/v1/projects/ # 从火山拉取项目列表 -# 方舟 API Key 管理(AirGate 代为操作,子账号只看到自己项目的 Key) -GET /api/v1/ark-keys/{project_name}/ # 列出指定项目下的 API Key -POST /api/v1/ark-keys/{project_name}/create/ # 在指定项目下创建 API Key -POST /api/v1/ark-keys/{key_id}/disable/ # 停用 API Key -POST /api/v1/ark-keys/{key_id}/enable/ # 启用 API Key -DELETE /api/v1/ark-keys/{key_id}/ # 删除 API Key +# 方舟 API Key 管理(管理员手动录入,子账号只能查看自己的 Key) +GET /api/v1/ark-keys/ # 列出 API Key(管理员看全部,子账号看自己的) +POST /api/v1/ark-keys/ # 录入 API Key(管理员操作) +PUT /api/v1/ark-keys/{id}/ # 更新 API Key(启用/停用/改备注) +DELETE /api/v1/ark-keys/{id}/ # 删除 API Key(管理员操作) +GET /api/v1/ark-keys/{id}/reveal/ # 查看完整 Key(解密展示) # 管理员管理 GET /api/v1/auth/admins/ # 列出所有管理员 @@ -1159,68 +1159,62 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权 ## 12. 方舟 API Key 管理 -### 12.1 接口发现(2026-03-20 实测验证) +### 12.1 方舟 Open API 调研结果(2026-03-20 实测) -方舟 API Key 管理使用 **POST + JSON body** 方式调用,与 IAM API 的 GET + Query 方式不同。 +方舟 API Key 管理接口使用 **POST + JSON body** 方式调用(与 IAM 的 GET + Query 不同)。 -| 参数 | 值 | -|------|-----| -| 端点 | `open.volcengineapi.com` | -| Service | `ark` | -| Version | `2024-01-01` | -| HTTP 方法 | **POST**(必须,GET 不传 body 会报 MissingParameter) | -| Content-Type | `application/json` | -| 签名 | HMAC-SHA256,signed_headers 包含 `content-type;host;x-content-sha256;x-date` | +| 接口 | 说明 | 状态 | +|------|------|------| +| `ListApiKeys` | 列出项目下的 API Key(返回脱敏值 `fedd****a052`) | **已验证** | +| `CreateApiKey` | 创建 API Key(**仅返回 ID,不返回明文 Key**) | **已验证** | +| `DeleteApiKey` | 删除 API Key | **已验证** | +| `GetApiKey` | 需要 `DurationSeconds`,疑似生成临时凭证,非查询明文 | **不适用** | -### 12.2 已验证的接口 +### 12.2 关键限制 -```python -# ListApiKeys - 列出项目下的 API Key -POST https://open.volcengineapi.com/?Action=ListApiKeys&Version=2024-01-01 -Body: {"ProjectName": "zyc_test", "PageSize": 10} +> **方舟 API Key 的明文(完整 Key)只有在火山控制台网页上创建时才会显示一次。通过 Open API 创建的 Key 无法获取明文,`ListApiKeys` 返回的永远是脱敏值。** -# 返回结果包含: -# - TotalCount: 总数 -# - Items[].Id: Key ID -# - Items[].Key: "fedd****a052"(脱敏) -# - Items[].ProjectName: 所属项目 -# - Items[].Name: Key 名称 -# - Items[].Status: Active/Inactive -# - Items[].Tags[]: 包含创建者信息(如 IAMUser/76804896/zyc) -``` +这意味着通过 API 自动化创建 Key 后,用户拿不到可用的 Key 值。 -### 12.3 待验证的接口 +### 12.3 最终方案:管理员手动录入 -以下接口需要实际调用验证参数: - -```python -# CreateApiKey - 创建 API Key -POST ?Action=CreateApiKey&Version=2024-01-01 -Body: {"ProjectName": "xxx", "Name": "key-name", "ResourceInstances": [...]} - -# DeleteApiKey - 删除 API Key -POST ?Action=DeleteApiKey&Version=2024-01-01 -Body: {"ApiKeyId": "xxx"} - -# UpdateApiKey - 更新 API Key(启用/停用) -POST ?Action=UpdateApiKey&Version=2024-01-01 -Body: {"ApiKeyId": "xxx", "Status": "Active/Inactive"} -``` - -### 12.4 AirGate 集成方案 - -AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: +鉴于上述限制,AirGate 采用**手动录入模式**: ``` -子账号登录 AirGate - → 看到自己关联的项目 - → 选择项目 → 查看该项目下的 API Key(只看自己项目的) - → 创建新 Key / 停用 Key / 删除 Key - → AirGate 后端用主账号 AK/SK 调用方舟 API 执行操作 - → 项目级隔离由 AirGate 应用层控制(查询时只传子账号关联的项目名) +管理员操作流程: + 1. 登录火山控制台 → 切到子账号的项目 → 创建 API Key → 复制完整 Key + 2. 登录 AirGate → API Key 管理 → 录入 Key(绑定到子账号 + 项目) + 3. 子账号登录 AirGate → 只能看到自己的 Key + +数据模型(存入 AirGate 数据库): + ArkApiKey: + - iam_user: FK → IAMUser(所属子账号) + - project_name: 所属项目名 + - key_name: Key 名称/用途说明 + - api_key_enc: 加密存储的完整 API Key(AES-256) + - api_key_hint: 脱敏显示(前4后4) + - status: active / disabled + - created_by: 谁录入的 + - created_at / updated_at ``` -**关键**:子账号不需要火山控制台的任何权限来管理 API Key,因为所有操作都由 AirGate 使用主账号身份代为执行。 +### 12.4 安全设计 + +- **加密存储**:API Key 使用与主账号 AK/SK 相同的 `AIRGATE_ENCRYPTION_KEY` 加密存储 +- **按需解密**:子账号查看 Key 时解密展示,页面关闭后不保留 +- **权限隔离**:子账号只能看到绑定给自己的 Key,管理员能看到所有 +- **操作审计**:Key 的录入、查看、停用、删除均记录到操作日志 + +### 12.5 火山控制台操作保留 + +以下操作仍需管理员在火山控制台完成(无法通过 API 替代): + +| 操作 | 在哪里做 | 频率 | +|------|----------|------| +| 创建火山项目 | 火山控制台 | 低(新团队入驻时) | +| 在项目下开通模型端点 | 火山控制台 | 低(新模型接入时) | +| 创建方舟 API Key | 火山控制台 | 低(按需创建) | +| **其他所有操作** | **AirGate** | 日常 | --- @@ -1257,7 +1251,7 @@ AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: | 操作 | 在哪里做 | |------|----------| -| 创建/查看/删除 API Key | AirGate(代调方舟 API) | +| 查看自己的 API Key | AirGate(管理员录入,子账号查看) | | 查看消费 | AirGate(代调 Billing API) | | 管理项目 | AirGate(管理员操作) | | 使用 Seedance 2.0 | 直接用 API Key 调用(不需要控制台) | @@ -1287,6 +1281,7 @@ AirGate 作为子账号的唯一操作入口,代理方舟 API Key 管理: | Billing API QPS 限制 5 | 批量查询需注意限流 | | **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 | | **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 | +| **方舟 CreateApiKey 不返回 Key 明文** | 只返回 ID,ListApiKeys 返回脱敏值,明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 | | 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 | | 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 | From daa82aee76358b68361b4049e0462874cd703dc5 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 01:30:35 +0800 Subject: [PATCH 07/40] feat: add sub-account login to AirGate - IAMUser model: login_password_hash + login_enabled fields - Custom JWT auth for sub-accounts (role: iam_user) - Login/me/my-keys/reveal endpoints for sub-accounts - Admin can set login password via set-login endpoint - Sub-accounts can only see their own API Keys Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/accounts/urls.py | 6 + backend/apps/accounts/views.py | 157 +++++++++++++++++- ...gin_enabled_iamuser_login_password_hash.py | 23 +++ backend/apps/monitor/models.py | 11 ++ backend/apps/monitor/serializers.py | 1 + backend/apps/monitor/urls.py | 1 + backend/apps/monitor/views.py | 36 ++++ 7 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 23c181a..e8472ba 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -10,4 +10,10 @@ urlpatterns = [ path('admins/create/', views.admin_create_view), path('admins//toggle/', views.admin_toggle_view), path('admins//reset-password/', views.admin_reset_password_view), + + # Sub-account (IAM user) login + path('iam/login/', views.iam_login_view), + path('iam/me/', views.iam_me_view), + path('iam/my-keys/', views.iam_my_keys_view), + path('iam/my-keys//reveal/', views.iam_my_key_reveal_view), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index e5f7d67..83377af 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,6 +1,6 @@ from django.contrib.auth import authenticate from rest_framework import status -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken @@ -195,3 +195,158 @@ def admin_reset_password_view(request, pk): ) return Response({'message': f'已重置 {user.username} 的密码'}) + + +# ==================== Sub-account (IAM User) Login ==================== + +@api_view(['POST']) +@permission_classes([AllowAny]) +def iam_login_view(request): + """子账号登录 AirGate""" + username = request.data.get('username', '') + password = request.data.get('password', '') + + if not username or not password: + return Response({'error': 'missing', 'message': '请输入用户名和密码'}, + status=status.HTTP_400_BAD_REQUEST) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(username=username) + except IAMUser.DoesNotExist: + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + if not iam_user.login_enabled: + return Response({'error': 'login_disabled', 'message': '此账号未开通 AirGate 登录'}, + status=status.HTTP_403_FORBIDDEN) + + if iam_user.status != IAMUser.Status.ACTIVE: + return Response({'error': 'user_disabled', 'message': '账号已停用'}, + status=status.HTTP_403_FORBIDDEN) + + if not iam_user.check_login_password(password): + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + # Generate JWT token with iam_user info (use a dummy admin user for simplejwt) + import jwt + from django.conf import settings + from datetime import datetime, timedelta, timezone + payload = { + 'token_type': 'access', + 'iam_user_id': iam_user.id, + 'username': iam_user.username, + 'role': 'iam_user', + 'exp': datetime.now(timezone.utc) + timedelta(hours=24), + 'iat': datetime.now(timezone.utc), + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + + return Response({ + 'access': token, + 'user': { + 'id': iam_user.id, + 'username': iam_user.username, + 'display_name': iam_user.display_name, + 'role': 'iam_user', + } + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_me_view(request): + """子账号获取自身信息(通过 JWT token 中的 iam_user_id)""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return Response({'error': 'token_expired'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(pk=payload['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + from apps.monitor.serializers import IAMUserSerializer + return Response({ + 'role': 'iam_user', + 'user': IAMUserSerializer(iam_user).data, + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_keys_view(request): + """子账号查看自己的 API Key""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from apps.monitor.serializers import ArkApiKeySerializer + keys = ArkApiKey.objects.filter(iam_user_id=payload['iam_user_id']) + return Response(ArkApiKeySerializer(keys, many=True).data) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_key_reveal_view(request, pk): + """子账号查看自己的 API Key 明文""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from utils.crypto import decrypt + try: + key = ArkApiKey.objects.get(pk=pk, iam_user_id=payload['iam_user_id']) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + return Response({ + 'api_key': decrypt(key.api_key_enc), + 'key_name': key.key_name, + 'project_name': key.project_name, + }) diff --git a/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py new file mode 100644 index 0000000..411e4e1 --- /dev/null +++ b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0007_arkapikey'), + ] + + operations = [ + migrations.AddField( + model_name='iamuser', + name='login_enabled', + field=models.BooleanField(default=False, verbose_name='允许登录 AirGate'), + ), + migrations.AddField( + model_name='iamuser', + name='login_password_hash', + field=models.CharField(blank=True, max_length=256, verbose_name='AirGate 登录密码哈希'), + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index 66b5411..dfc54bb 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -1,4 +1,5 @@ from decimal import Decimal +from django.contrib.auth.hashers import make_password, check_password from django.db import models @@ -37,6 +38,10 @@ class IAMUser(models.Model): phone = models.CharField('手机号', max_length=20, blank=True) status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN) + # AirGate 本地登录密码(子账号用来登录 AirGate,与火山控制台无关) + login_password_hash = models.CharField('AirGate 登录密码哈希', max_length=256, blank=True) + login_enabled = models.BooleanField('允许登录 AirGate', default=False) + # Access keys (stored as JSON list of AK IDs, not secrets) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) @@ -73,6 +78,12 @@ class IAMUser(models.Model): def __str__(self): return f"{self.display_name or self.username} ({self.status})" + def set_login_password(self, raw_password): + self.login_password_hash = make_password(raw_password) + + def check_login_password(self, raw_password): + return check_password(raw_password, self.login_password_hash) + @property def remaining_quota(self): """剩余额度""" diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index a33e961..31e37c6 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -41,6 +41,7 @@ class IAMUserSerializer(serializers.ModelSerializer): 'alert_thresholds', 'triggered_alerts', 'effective_alert_thresholds', 'projects', 'monitored_project_count', + 'login_enabled', 'remark', 'created_at', 'updated_at', ] read_only_fields = ['user_id', 'access_key_ids', 'status', diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 80fb5b8..7ff3cb5 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('iam-users/import/', views.iam_user_import_view), path('iam-users//', views.iam_user_detail_view), path('iam-users//update/', views.iam_user_update_view), + path('iam-users//set-login/', views.iam_user_set_login_view), path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), path('iam-users//policies/', views.iam_user_policies_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 70fd797..e4a21d3 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -369,6 +369,42 @@ def iam_user_update_view(request, pk): return Response(IAMUserSerializer(user).data) +@api_view(['POST']) +def iam_user_set_login_view(request, pk): + """设置子账号的 AirGate 登录密码""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + password = request.data.get('password', '') + enabled = request.data.get('login_enabled') + + if password: + if len(password) < 6: + return Response({'error': 'weak_password', 'message': '密码至少6位'}, + status=status.HTTP_400_BAD_REQUEST) + user.set_login_password(password) + user.login_enabled = True + + if enabled is not None: + user.login_enabled = enabled + + user.save(update_fields=['login_password_hash', 'login_enabled']) + + AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"设置子账号 {user.username} 的 AirGate 登录", + content=f"操作人: {request.user.username},登录: {'开启' if user.login_enabled else '关闭'}", + ) + + return Response({ + 'message': f'已{"开启" if user.login_enabled else "关闭"}子账号 {user.username} 的 AirGate 登录', + 'login_enabled': user.login_enabled, + }) + + @api_view(['POST']) def iam_user_disable_view(request, pk): """停用子账号""" From 3d2b33265790b14b66ab3dd9025b51da202d6cf4 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 01:33:02 +0800 Subject: [PATCH 08/40] feat: add sub-account portal (login + my keys view) - Login page: toggle between admin/sub-account login - Auth store: isAdmin/isIamUser computed properties - MainLayout: role-based sidebar (admin sees all, sub-account sees only my keys) - HomeRedirect: auto-redirect based on role - MyKeysView: sub-account can view/reveal their own API Keys - Portal is completely isolated from admin functions Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/nginx.conf | 2 +- frontend/src/layouts/MainLayout.vue | 73 +++++++++++++--------- frontend/src/router/index.js | 11 +++- frontend/src/stores/auth.js | 8 ++- frontend/src/views/HomeRedirect.vue | 20 ++++++ frontend/src/views/LoginView.vue | 22 +++++-- frontend/src/views/portal/MyKeysView.vue | 77 ++++++++++++++++++++++++ 7 files changed, 175 insertions(+), 38 deletions(-) create mode 100644 frontend/src/views/HomeRedirect.vue create mode 100644 frontend/src/views/portal/MyKeysView.vue diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 64b00e1..e56a400 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,7 +5,7 @@ server { index index.html; location /api/ { - proxy_pass http://airgate-backend:8100; + proxy_pass http://backend:8100; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 8d2406a..137d154 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -4,41 +4,56 @@ - - - 仪表盘 - - - - 子账号管理 - - - - API Key 管理 - - - - 消费监控 - - - - 告警记录 - - - - 系统设置 - - - - 系统管理 - + + + + + + - {{ auth.user?.username }} + 子账号 + + {{ auth.user?.display_name || auth.user?.username }} + 退出登录 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 29c7ff2..5f7cb7e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -12,13 +12,22 @@ const routes = [ path: '/', component: () => import('../layouts/MainLayout.vue'), children: [ - { path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') }, + // Dynamic home: admin sees Dashboard, iam_user sees MyKeys + { + path: '', + name: 'Home', + component: () => import('../views/HomeRedirect.vue'), + }, + // Admin routes + { path: 'dashboard', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') }, { path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') }, { path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') }, { path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') }, { path: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') }, { path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') }, { path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') }, + // IAM user (sub-account) routes + { path: 'my-keys', name: 'MyKeys', component: () => import('../views/portal/MyKeysView.vue') }, ], }, ] diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index fff08d1..3c8f32d 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -7,13 +7,15 @@ export const useAuthStore = defineStore('auth', () => { const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null')) const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role !== 'iam_user') + const isIamUser = computed(() => user.value?.role === 'iam_user') function setAuth(data) { token.value = data.access - refreshToken.value = data.refresh + refreshToken.value = data.refresh || '' user.value = data.user localStorage.setItem('airgate_token', data.access) - localStorage.setItem('airgate_refresh', data.refresh) + localStorage.setItem('airgate_refresh', data.refresh || '') localStorage.setItem('airgate_user', JSON.stringify(data.user)) } @@ -26,5 +28,5 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('airgate_user') } - return { token, refreshToken, user, isLoggedIn, setAuth, logout } + return { token, refreshToken, user, isLoggedIn, isAdmin, isIamUser, setAuth, logout } }) diff --git a/frontend/src/views/HomeRedirect.vue b/frontend/src/views/HomeRedirect.vue new file mode 100644 index 0000000..01acfd9 --- /dev/null +++ b/frontend/src/views/HomeRedirect.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 7a04860..88d1b79 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -5,9 +5,14 @@

AirGate

火山引擎 IAM 子账号管控平台

+ + + - + +
+

我的 API Key

+ + + + + + + + + + + + +
+ + + From 33c8963d46903a036b01cf7ab370dbd4628915a9 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 01:35:35 +0800 Subject: [PATCH 09/40] fix: add explicit role field to admin login response Makes auth role logic consistent between admin (role: 'admin') and sub-account (role: 'iam_user') logins. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/accounts/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 4ca71f5..c5a0a1c 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -13,6 +13,10 @@ class UserInfoSerializer(serializers.Serializer): is_active = serializers.BooleanField() date_joined = serializers.DateTimeField() last_login = serializers.DateTimeField() + role = serializers.SerializerMethodField() + + def get_role(self, obj): + return 'admin' class ChangePasswordSerializer(serializers.Serializer): From fac5e1b54170273b1f2dea1a87658f751a898871 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 15:54:35 +0800 Subject: [PATCH 10/40] feat: password management for admin and sub-accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin: set sub-account AirGate login password via dropdown menu - Admin: toggle sub-account login enabled/disabled - Sub-account: change own password (sidebar "修改密码") - Sub-account: auto-redirect to login page after password change Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/accounts/urls.py | 1 + backend/apps/accounts/views.py | 56 +++++++++++++++++ frontend/src/layouts/MainLayout.vue | 4 ++ frontend/src/router/index.js | 1 + frontend/src/views/iam/IAMUserList.vue | 56 +++++++++++++++++ frontend/src/views/portal/MyPasswordView.vue | 66 ++++++++++++++++++++ 6 files changed, 184 insertions(+) create mode 100644 frontend/src/views/portal/MyPasswordView.vue diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index e8472ba..3ee1e49 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -16,4 +16,5 @@ urlpatterns = [ path('iam/me/', views.iam_me_view), path('iam/my-keys/', views.iam_my_keys_view), path('iam/my-keys//reveal/', views.iam_my_key_reveal_view), + path('iam/change-password/', views.iam_change_password_view), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 83377af..ce1685f 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -350,3 +350,59 @@ def iam_my_key_reveal_view(request, pk): 'key_name': key.key_name, 'project_name': key.project_name, }) + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_change_password_view(request): + """子账号修改自己的 AirGate 登录密码""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(pk=payload['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + old_password = request.data.get('old_password', '') + new_password = request.data.get('new_password', '') + + if not old_password or not new_password: + return Response({'error': 'missing', 'message': '请输入原密码和新密码'}, + status=status.HTTP_400_BAD_REQUEST) + + if not iam_user.check_login_password(old_password): + return Response({'error': 'wrong_password', 'message': '原密码错误'}, + status=status.HTTP_400_BAD_REQUEST) + + if len(new_password) < 6: + return Response({'error': 'weak_password', 'message': '密码至少6位'}, + status=status.HTTP_400_BAD_REQUEST) + + iam_user.set_login_password(new_password) + iam_user.save(update_fields=['login_password_hash']) + + from apps.monitor.models import AlertRecord + AlertRecord.objects.create( + iam_user=iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"子账号 {iam_user.username} 修改 AirGate 密码", + content=f"操作人: {iam_user.username}(自行修改)", + ) + + return Response({'message': '密码修改成功,请重新登录'}) diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 137d154..21a988f 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -43,6 +43,10 @@ 我的 API Key + + + 修改密码 + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 5f7cb7e..89b96b2 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -28,6 +28,7 @@ const routes = [ { path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') }, // IAM user (sub-account) routes { path: 'my-keys', name: 'MyKeys', component: () => import('../views/portal/MyKeysView.vue') }, + { path: 'my-password', name: 'MyPassword', component: () => import('../views/portal/MyPasswordView.vue') }, ], }, ] diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 9edaf90..faff8d0 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -80,6 +80,7 @@ 监控配置 权限策略 划拨记录 + 登录密码 停用账号
+ + + + + + + + + + + + + @@ -651,6 +671,42 @@ async function saveConfig() { } // --- Quota History --- +// === Set Login Password === +const loginPwdVisible = ref(false) +const loginPwdUser = ref(null) +const loginPwdValue = ref('') +const loginPwdEnabled = ref(false) +const loginPwdSaving = ref(false) + +function openSetLogin(row) { + loginPwdUser.value = row + loginPwdValue.value = '' + loginPwdEnabled.value = row.login_enabled || false + loginPwdVisible.value = true +} + +async function handleSetLogin() { + const payload = { login_enabled: loginPwdEnabled.value } + if (loginPwdValue.value) { + if (loginPwdValue.value.length < 6) { + ElMessage.warning('密码至少6位') + return + } + payload.password = loginPwdValue.value + } + loginPwdSaving.value = true + try { + const { data } = await api.post(`/api/v1/iam-users/${loginPwdUser.value.id}/set-login/`, payload) + ElMessage.success(data.message) + loginPwdVisible.value = false + await loadUsers() + } catch (e) { + ElMessage.error(e.response?.data?.message || '操作失败') + } finally { + loginPwdSaving.value = false + } +} + async function openQuotaHistory(row) { historyUser.value = row historyVisible.value = true diff --git a/frontend/src/views/portal/MyPasswordView.vue b/frontend/src/views/portal/MyPasswordView.vue new file mode 100644 index 0000000..b317e27 --- /dev/null +++ b/frontend/src/views/portal/MyPasswordView.vue @@ -0,0 +1,66 @@ + + + From b25641cfc662c9315b40254ccda685ccb85cc522 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 21 Mar 2026 17:51:45 +0800 Subject: [PATCH 11/40] docs: rewrite operations guide with admin/sub-account sections - Admin section: full workflow from account creation to key entry - Sub-account section: login, view keys, change password, use API - Updated for manual key entry mode and no-console-login architecture - Added alert/auto-disable mechanism description Co-Authored-By: Claude Opus 4.6 (1M context) --- 操作说明.md | 164 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 117 insertions(+), 47 deletions(-) diff --git a/操作说明.md b/操作说明.md index 6be3fd2..972a362 100644 --- a/操作说明.md +++ b/操作说明.md @@ -17,7 +17,7 @@ cd C:\Airlabs_Project\AirGate\frontend npm run dev ``` -打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录。 +打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录(首次登录后请立即修改密码)。 ### 2. 配置火山主账号 @@ -35,13 +35,12 @@ npm run dev | 配置项 | 说明 | 建议值 | |--------|------|--------| | 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 | -| 项目默认授权策略 | 添加项目时自动授权的策略 | ArkFullAccess,TOSFullAccess | | 监控间隔(秒) | 定时查询消费的间隔 | 3600(1小时) | | 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 | --- -## 二、日常操作 +## 二、管理员操作 ### 给新部门开通子账号 @@ -51,44 +50,58 @@ npm run dev 2. 填写: - **用户名**:英文,如 `dept_video` - **显示名**:如 `视频部门` - - **火山控制台密码**:填上(对方需要登录火山后台创建方舟 API Key) + - **火山控制台密码**:不填(子账号不需要登录火山控制台) - 其他选填 3. 点 **创建** -4. 弹窗显示 API 密钥 → **立即复制保存**(SecretKey 仅显示一次) +4. 弹窗显示 IAM API 密钥 → **立即复制保存**(SecretKey 仅显示一次) -**步骤 2:在火山控制台创建项目** +**步骤 2:在火山控制台创建项目并开通模型** 1. 登录 `console.volcengine.com`(你的主账号) -2. 左上角项目管理 → 新建项目(如 `team-video-1`) +2. 项目管理 → 新建项目(如 `team-video-1`) +3. 进入方舟平台 → 切到该项目 → 开通 Seedance 2.0 端点 +4. 在该项目下创建方舟 API Key → **复制完整 Key** **步骤 3:在 AirGate 关联项目并授权** 1. 回到 AirGate → 子账号管理 → 找到刚创建的子账号 2. 点 **更多 → 项目管理** 3. 从下拉框选择刚创建的项目 → 点 **添加** -4. 系统自动在项目范围内授权 ArkFullAccess + TOSFullAccess +4. 点项目行上的 **授权** 按钮 → 勾选需要的策略(如 `ArkFullAccess` + `TOSFullAccess`)→ 保存 -**步骤 4:划拨额度** +**步骤 4:录入 API Key** -1. 点子账号的 **划拨** 按钮 -2. 选择「追加额度」,输入金额(如 100000) -3. 填备注(如 `首次划拨`)→ 确认 +1. 左侧菜单 → **API Key 管理** → 点 **录入 API Key** +2. 选择子账号、所属项目 +3. 填写名称/用途、粘贴完整的 API Key +4. 点 **录入** → Key 加密存储 -**步骤 5:告知对方** +**步骤 5:划拨额度** + +1. 子账号管理 → 点子账号的 **划拨** 按钮 +2. 输入金额(如 100000)和备注 → 确认 + +**步骤 6:设置子账号的 AirGate 登录密码** + +1. 子账号管理 → 更多 → **登录密码** +2. 输入密码 → 开启「允许登录」→ 保存 + +**步骤 7:告知对方** 发给对方以下信息: -- 火山控制台登录地址:`https://console.volcengine.com` +- AirGate 登录地址:`http://你的部署地址` +- 登录方式:选择「子账号登录」 - 用户名:`dept_video` - 密码:你设置的密码 -- 登录后选择项目 `team-video-1`,进入方舟平台创建 Seedance 2.0 的 API Key +- 登录后在「我的 API Key」中查看和复制 Key --- ### 给子账号追加/扣减额度 1. 子账号管理 → 找到目标用户 → 点 **划拨** -2. 选择「追加额度」或「扣减额度」 -3. 输入金额和备注 → 确认 +2. 输入正数追加、负数扣减 +3. 填备注 → 确认 > 扣减有保护:总额度不能低于已消费金额 @@ -96,9 +109,18 @@ npm run dev ### 给子账号增加新项目 -1. 先在火山控制台创建新项目 -2. 回到 AirGate → 子账号管理 → 更多 → **项目管理** -3. 从下拉框选择新项目 → 添加(自动授权) +1. 先在火山控制台创建新项目 + 开通模型端点 + 创建 API Key +2. 回到 AirGate → 子账号管理 → 更多 → **项目管理** → 添加项目 +3. 点 **授权** 选择策略 +4. 去 **API Key 管理** 录入新的 Key + +--- + +### 管理子账号的项目级权限 + +1. 子账号管理 → 更多 → **项目管理** +2. 找到目标项目 → 点 **授权** +3. 勾选/取消策略 → 保存(增量更新:只添加新的、移除取消的) --- @@ -106,7 +128,7 @@ npm run dev 1. 子账号管理 → 更多 → **项目管理** 2. 找到目标项目 → 关闭「监测」开关 -3. 该项目的消费不再计入子账号的累计消费(不影响告警和停用判断) +3. 该项目的消费不再计入子账号的累计消费 --- @@ -114,11 +136,20 @@ npm run dev **停用:** 1. 子账号管理 → 更多 → **停用账号** -2. 确认后,子账号的控制台登录和所有 API Key 立即失效 +2. 系统自动执行三步:停用控制台登录 + 停用所有 API Key + 移除所有权限策略 +3. 子账号刷新页面后立即无法操作 **恢复:** 1. 子账号管理 → 更多 → **恢复账号** -2. 确认后,控制台登录和 API Key 立即恢复 +2. 自动恢复控制台登录 + API Key + 权限策略(从停用时的快照恢复) + +--- + +### 查看/管理 API Key + +1. 左侧菜单 → **API Key 管理** +2. 可按子账号、项目筛选 +3. 操作:查看明文 / 启用 / 停用 / 删除 --- @@ -126,41 +157,77 @@ npm run dev 1. 左侧菜单 → **消费监控** 2. 表格展示每个子账号的累计消费、额度、使用率 -3. 点行首的 **展开箭头**,查看该子账号各项目的独立消费 -4. 点 **刷新消费数据** 手动触发一次消费查询 -5. 点 **查看主账号余额** 查看主账号的可用余额 +3. 点行首展开查看各项目的独立消费 +4. 点 **刷新消费数据** 手动触发查询 +5. 点 **查看主账号余额** 查看可用余额 > 消费数据来自火山 Billing API,有 1-2 天延迟 --- -### 查看告警记录 +### 查看告警/操作日志 -1. 左侧菜单 → **告警记录** -2. 可按类型筛选:告警 / 自动停用 / 手动操作 / 错误 +1. 左侧菜单 → **告警记录** — 查看告警和自动停用记录 +2. 左侧菜单 → **系统管理** → **操作日志** — 查看所有操作记录(含类型筛选) --- -### 修改子账号的告警阶梯 +### 管理员账号管理 -1. 子账号管理 → 更多 → **监控配置** -2. 修改告警阶梯百分比(如添加 95%) -3. 开关消费监控 / 额度用尽自动停用 -4. 保存 +1. 左侧菜单 → **系统管理** +2. **修改密码**:修改当前管理员的密码 +3. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码 +4. **操作日志**:查看所有系统操作记录 --- -### 查看/管理子账号的权限策略 +## 三、子账号操作 -1. 子账号管理 → 更多 → **权限策略** -2. 上方可从下拉框选择策略手动附加 -3. 已有策略列表中可点 **移除** +> 子账号使用独立的登录入口,不需要登录火山控制台。 -> 常规情况不需要手动管理权限,添加项目时会自动授权 +### 登录 + +1. 打开 AirGate 登录页面 +2. 切换到 **「子账号登录」** +3. 输入用户名和密码(由管理员提供) + +### 查看我的 API Key + +1. 登录后默认进入 **「我的 API Key」** 页面 +2. 显示管理员分配给你的所有 API Key +3. 点 **查看** 显示完整 Key 明文 → 点 **复制** 复制到剪贴板 +4. Key 状态:启用(可用)/ 停用(不可用,联系管理员) + +### 修改密码 + +1. 左侧菜单 → **修改密码** +2. 输入原密码 + 新密码 → 确认 +3. 修改成功后自动跳转到登录页,需要用新密码重新登录 + +### 使用 API Key 调用服务 + +拿到 API Key 后,直接调用火山方舟的 API: + +```python +import requests + +API_KEY = "你在 AirGate 看到的 Key" +url = "https://ark.cn-beijing.volces.com/api/v3/chat/completions" + +headers = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +# 调用示例(以 Seedance 2.0 为例) +response = requests.post(url, headers=headers, json={...}) +``` + +> 不需要登录火山控制台,API Key 可以直接使用。 --- -## 三、告警与自动停用机制 +## 四、告警与自动停用机制 ``` 定时任务每小时运行一次 @@ -183,10 +250,11 @@ npm run dev - 每个阶梯只通知一次,不会重复 - 追加或扣减额度后,告警状态自动重置 - 「额度用尽自动停用」可在监控配置中关闭(只告警不停用) +- 停用会同时移除所有权限策略,确保即使有活跃会话也立即失效 --- -## 四、外部系统对接(AirDrama) +## 五、外部系统对接(AirDrama) AirGate 支持通过 API Key 认证供外部系统调用: @@ -199,14 +267,16 @@ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/iam-users/ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/ ``` -完整 API 列表见 [README.md](README.md) 或研究报告第 11 章。 +完整 API 列表见研究报告第 11 章。 --- -## 五、注意事项 +## 六、注意事项 1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量 -2. **SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了,务必保存 -3. **项目由你创建**:子账号没有创建项目的权限,需要新项目时在火山控制台创建后在 AirGate 关联 -4. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭 -5. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的火山主账号 AK/SK 无法解密,需要重新配置 +2. **IAM SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了 +3. **方舟 API Key 由管理员录入**:火山 API 不返回 Key 明文,需要在火山控制台创建后手动录入 AirGate +4. **子账号不登录火山控制台**:所有操作通过 AirGate 完成,避免权限泄露 +5. **项目由管理员创建**:子账号没有创建项目的权限,需要新项目时联系管理员 +6. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭 +7. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的密钥无法解密 From 8b49d490482ad89e8beee248a8588c9aa90b57dc Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 15:57:02 +0800 Subject: [PATCH 12/40] feat: add edit sub-account profile + verify all password features - Add edit profile (display name, phone, email) with Volcengine sync - Add IAMService.update_user for Volcengine UpdateUser API - Add edit-profile API endpoint and URL - Add Edit Profile dialog in IAMUserList frontend - Verify admin change password, sub-account change password, set login password all working Co-Authored-By: Claude Opus 4.6 (1M context) --- API方案.md | 33 ++++++++++++++ backend/apps/monitor/urls.py | 1 + backend/apps/monitor/views.py | 44 +++++++++++++++++++ backend/utils/iam_service.py | 11 +++++ docker-compose.yml | 6 +-- frontend/src/views/iam/IAMUserList.vue | 60 ++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 API方案.md diff --git a/API方案.md b/API方案.md new file mode 100644 index 0000000..25b528e --- /dev/null +++ b/API方案.md @@ -0,0 +1,33 @@ +方案一:火山 IAM 子账号(推荐) +思路:给队友创建火山子用户,让他直连火山 API。 + +给他的东西 +用途 给什么 +Assets API 子账号的 AK/SK +Seedance 调用 子账号的 Ark API Key + 专用接入点(只绑 Seedance 2.0) +对账 控制台登录密码 +项目标识 ProjectName: "int_dev_Airlabs" +权限范围 +✅ Ark 模型调用、Assets 素材管理、费用中心(只读) +❌ IAM 管理、其他云服务、充值/支付 +优缺点 +✅ 零开发量,控制台几分钟搞定 +✅ 他能自己对账 +✅ 权限可控,随时可禁用/删除 +✅ 互不影响,你的服务挂了不影响他 +❌ 他能直接接触火山资源(但权限受限) +方案二:后端转发 +思路:你的后端包一层,队友只调你的接口,AK/SK 不出服务器。 + +给他的东西 +用途 给什么 +所有调用 后端地址 + 账号密码(JWT 认证) +优缺点 +✅ 队友什么密钥都不需要,最安全 +✅ 你能完全掌控调用行为 +❌ 要写新接口把火山 API 都包一遍 +❌ 对账要你自己做用量统计页面 +❌ 多一跳,依赖你的服务稳定性 +❌ 火山 API 变更你得跟着维护 +一句话结论 +队友是自己人 → 方案一,省事;对外卖服务/不信任对方 → 方案二,可控。 \ No newline at end of file diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 7ff3cb5..61ebba9 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('iam-users/import/', views.iam_user_import_view), path('iam-users//', views.iam_user_detail_view), path('iam-users//update/', views.iam_user_update_view), + path('iam-users//edit-profile/', views.iam_user_edit_profile_view), path('iam-users//set-login/', views.iam_user_set_login_view), path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index e4a21d3..58080f7 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -369,6 +369,50 @@ def iam_user_update_view(request, pk): return Response(IAMUserSerializer(user).data) +@api_view(['POST']) +def iam_user_edit_profile_view(request, pk): + """编辑子账号信息(显示名、手机号、邮箱),同步到火山""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + display_name = request.data.get('display_name') + email = request.data.get('email') + phone = request.data.get('phone') + + # Update on Volcengine + account = user.volc_account + ak = decrypt(account.access_key_enc) + sk = decrypt(account.secret_key_enc) + iam = IAMService(ak, sk) + + try: + iam.update_user(user.username, + display_name=display_name, + email=email, + phone=phone) + except VolcengineAPIError as e: + return Response({'message': f'火山 API 更新失败: {e}'}, + status=status.HTTP_400_BAD_REQUEST) + + # Update locally + if display_name is not None: + user.display_name = display_name + if email is not None: + user.email = email + if phone is not None: + user.phone = phone + user.save() + + AlertRecord.objects.create( + iam_user=user, alert_type='manual', + title=f'编辑子账号信息 {user.username}', + content=f'操作人: {request.user.username}', + ) + return Response({'message': '已更新', 'user': IAMUserSerializer(user).data}) + + @api_view(['POST']) def iam_user_set_login_view(request, pk): """设置子账号的 AirGate 登录密码""" diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index 7eca1c4..cd644a7 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -76,6 +76,17 @@ class IAMService: "PolicyType": policy_type, }) + def update_user(self, username: str, display_name: str = None, + email: str = None, phone: str = None) -> dict: + params = {"UserName": username} + if display_name is not None: + params["NewDisplayName"] = display_name + if email is not None: + params["NewEmail"] = email + if phone is not None: + params["NewMobilePhone"] = phone + return self.client.call("UpdateUser", params) + def list_attached_user_policies(self, username: str) -> dict: return self.client.call("ListAttachedUserPolicies", {"UserName": username}) diff --git a/docker-compose.yml b/docker-compose.yml index 62ef58c..ce01bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: - airgate-backend: + backend: build: ./backend ports: - "8101:8100" @@ -15,12 +15,12 @@ services: - backend-data:/app/data restart: unless-stopped - airgate-web: + frontend: build: ./frontend ports: - "5174:80" depends_on: - - airgate-backend + - backend restart: unless-stopped volumes: diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index faff8d0..a444be2 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -78,6 +78,7 @@ 项目管理 监控配置 + 编辑信息 权限策略 划拨记录 登录密码 @@ -343,6 +344,32 @@ + + + + + + + + + + + + + + + + +
+ 修改会同步到火山引擎 IAM +
+ +
+ Date: Sat, 28 Mar 2026 19:27:36 +0800 Subject: [PATCH 13/40] fix: show clear error when Volcengine console password is too weak - Detect InvalidPassword error and return user-friendly message - Rollback user creation if password policy fails - Add password requirements hint in create form Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 10 ++++++++++ frontend/src/views/iam/IAMUserList.vue | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 58080f7..66da080 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -253,6 +253,16 @@ def iam_user_create_view(request): svc.create_login_profile(d['username'], password) result_info['login_enabled'] = True except VolcengineAPIError as e: + if 'InvalidPassword' in str(e): + # Rollback: delete the user we just created + try: + svc.client.call("DeleteUser", {"UserName": d['username']}) + except Exception: + pass + return Response({ + 'message': f'火山控制台密码不符合要求(需包含大小写字母、数字和特殊字符,至少8位)', + 'detail': str(e), + }, status=status.HTTP_400_BAD_REQUEST) result_info['login_error'] = str(e) # 3. Create access key diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index a444be2..19095c7 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -327,7 +327,8 @@
- 火山引擎网页后台的登录密码。不填则子账号无法登录火山网页后台,仅能通过 API Key 使用服务 + 火山引擎网页后台的登录密码。不填则子账号无法登录火山网页后台,仅能通过 API Key 使用服务。 + 密码需包含大小写字母、数字和特殊字符,至少8位(如 User@1234)
From 48c55765c80e0d2f10aa7b051f73cf3e136e3d9f Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 19:38:39 +0800 Subject: [PATCH 14/40] feat: add toggle Volcengine console login button for sub-accounts - Add volc_login_allowed field to IAMUser model - Add toggle-volc-login API endpoint - Add toggle button in IAMUserList dropdown menu - Sync status on user creation and toggle Co-Authored-By: Claude Opus 4.6 (1M context) --- .../0009_iamuser_volc_login_allowed.py | 18 ++++++++ backend/apps/monitor/models.py | 1 + backend/apps/monitor/serializers.py | 2 +- backend/apps/monitor/urls.py | 1 + backend/apps/monitor/views.py | 44 +++++++++++++++++++ frontend/src/views/iam/IAMUserList.vue | 19 ++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py diff --git a/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py b/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py new file mode 100644 index 0000000..e00c99b --- /dev/null +++ b/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2026-03-28 11:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0008_iamuser_login_enabled_iamuser_login_password_hash'), + ] + + operations = [ + migrations.AddField( + model_name='iamuser', + name='volc_login_allowed', + field=models.BooleanField(default=False, verbose_name='允许登录火山控制台'), + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index dfc54bb..6a1565a 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -41,6 +41,7 @@ class IAMUser(models.Model): # AirGate 本地登录密码(子账号用来登录 AirGate,与火山控制台无关) login_password_hash = models.CharField('AirGate 登录密码哈希', max_length=256, blank=True) login_enabled = models.BooleanField('允许登录 AirGate', default=False) + volc_login_allowed = models.BooleanField('允许登录火山控制台', default=False) # Access keys (stored as JSON list of AK IDs, not secrets) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index 31e37c6..f17bd80 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -41,7 +41,7 @@ class IAMUserSerializer(serializers.ModelSerializer): 'alert_thresholds', 'triggered_alerts', 'effective_alert_thresholds', 'projects', 'monitored_project_count', - 'login_enabled', + 'login_enabled', 'volc_login_allowed', 'remark', 'created_at', 'updated_at', ] read_only_fields = ['user_id', 'access_key_ids', 'status', diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 61ebba9..9eeb425 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('iam-users//', views.iam_user_detail_view), path('iam-users//update/', views.iam_user_update_view), path('iam-users//edit-profile/', views.iam_user_edit_profile_view), + path('iam-users//toggle-volc-login/', views.iam_user_toggle_volc_login_view), path('iam-users//set-login/', views.iam_user_set_login_view), path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 66da080..22a8d09 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -252,6 +252,7 @@ def iam_user_create_view(request): try: svc.create_login_profile(d['username'], password) result_info['login_enabled'] = True + result_info['volc_login_allowed'] = True except VolcengineAPIError as e: if 'InvalidPassword' in str(e): # Rollback: delete the user we just created @@ -292,6 +293,7 @@ def iam_user_create_view(request): phone=d.get('phone', ''), status=IAMUser.Status.ACTIVE, access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [], + volc_login_allowed=result_info.get('volc_login_allowed', False), ) # 6. Auto-add project if specified @@ -379,6 +381,48 @@ def iam_user_update_view(request, pk): return Response(IAMUserSerializer(user).data) +@api_view(['POST']) +def iam_user_toggle_volc_login_view(request, pk): + """切换子账号的火山控制台登录权限""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + account = user.volc_account + ak = decrypt(account.access_key_enc) + sk = decrypt(account.secret_key_enc) + iam = IAMService(ak, sk) + + # Check current status + try: + resp = iam.get_login_profile(user.username) + current = resp.get('Result', {}).get('LoginProfile', {}).get('LoginAllowed', False) + except VolcengineAPIError as e: + if 'LoginProfileNotExist' in str(e): + return Response({'message': '该子账号未设置火山控制台密码,无法切换登录状态'}, + status=status.HTTP_400_BAD_REQUEST) + raise + + new_status = not current + try: + iam.update_login_allowed(user.username, new_status) + except VolcengineAPIError as e: + return Response({'message': f'操作失败: {e}'}, status=status.HTTP_400_BAD_REQUEST) + + user.volc_login_allowed = new_status + user.save(update_fields=['volc_login_allowed']) + + action = '开启' if new_status else '关闭' + AlertRecord.objects.create( + iam_user=user, alert_type='manual', + title=f'{action}火山控制台登录 {user.username}', + content=f'操作人: {request.user.username}', + ) + return Response({'message': f'已{action} {user.username} 的火山控制台登录', + 'volc_login_allowed': new_status}) + + @api_view(['POST']) def iam_user_edit_profile_view(request, pk): """编辑子账号信息(显示名、手机号、邮箱),同步到火山""" diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 19095c7..383bc6f 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -79,6 +79,9 @@ 项目管理 监控配置 编辑信息 + + {{ row.volc_login_allowed ? '🔓 关闭火山登录' : '🔒 开启火山登录' }} + 权限策略 划拨记录 登录密码 @@ -475,6 +478,22 @@ async function handleEnable(row) { } } +// Toggle Volcengine console login +async function toggleVolcLogin(row) { + const action = row.volc_login_allowed ? '关闭' : '开启' + await ElMessageBox.confirm( + `确定${action} "${row.username}" 的火山引擎控制台登录?`, + `${action}火山登录`, { type: 'warning' } + ) + try { + const { data } = await api.post(`/api/v1/iam-users/${row.id}/toggle-volc-login/`) + ElMessage.success(data.message) + await loadUsers() + } catch (e) { + ElMessage.error(e.response?.data?.message || '操作失败') + } +} + // Edit Profile const editProfileVisible = ref(false) const editProfileUser = ref(null) From 0f034b7b265f46ec595869bbeff3342f6d8462c2 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 20:30:56 +0800 Subject: [PATCH 15/40] feat: auto-manage Deny policy for project isolation - Add upsert_deny_policy / remove_deny_policy to IAMService - Auto-update Deny policy when adding/removing projects - Auto-create Deny policy on sub-account creation - Deny policy lists all non-authorized projects explicitly - Verified: cross-project ListAssetGroups and ListApiKeys are blocked - Updated research report with cross-project API findings (2026-03-28) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 24 +++++++ backend/utils/iam_service.py | 86 +++++++++++++++++++++++ 火山引擎IAM子账号管控工具_深度研究报告.md | 51 ++++++++++++-- 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 22a8d09..a062c5f 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -44,6 +44,21 @@ def _get_volc_account(volc_id=None): return account, ak, sk +def _update_deny_policy(user): + """更新子账号的 Deny 策略,只允许访问已关联的项目""" + account, ak, sk = _get_volc_account(user.volc_account_id) + if not ak: + return + svc = IAMService(ak, sk) + allowed_projects = list( + user.projects.values_list('project_name', flat=True) + ) + try: + svc.upsert_deny_policy(user.username, allowed_projects) + except Exception as e: + logger.error(f"更新 Deny 策略失败 ({user.username}): {e}") + + # ==================== Dashboard ==================== @api_view(['GET']) @@ -305,6 +320,9 @@ def iam_user_create_view(request): monitor_enabled=True, ) + # 7. Create Deny policy (project isolation) + _update_deny_policy(obj) + AlertRecord.objects.create( iam_user=obj, alert_type=AlertRecord.AlertType.MANUAL, @@ -751,6 +769,9 @@ def iam_user_project_add_view(request, pk): obj.attached_policies = attached obj.save(update_fields=['attached_policies']) + # 更新 Deny 策略(将新项目加入白名单) + _update_deny_policy(user) + AlertRecord.objects.create( iam_user=user, alert_type=AlertRecord.AlertType.MANUAL, @@ -878,6 +899,9 @@ def iam_user_project_delete_view(request, pk, pid): project.delete() + # 更新 Deny 策略(将移除的项目从白名单中删除) + _update_deny_policy(user) + result = {'message': f'已移除项目 {name},已回收权限: {detached}'} if detach_errors: result['detach_errors'] = detach_errors diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index cd644a7..f5c877e 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -110,6 +110,92 @@ class IAMService: "ProjectName": project_name, }) + # === Deny Policy (project isolation) === + + def _deny_policy_name(self, username: str) -> str: + return f"AirGate_Deny_{username}" + + def upsert_deny_policy(self, username: str, allowed_projects: list[str]): + """创建或更新子账号的 Deny 策略,只允许访问指定项目""" + import json + policy_name = self._deny_policy_name(username) + + # Get all projects to build explicit deny list + from .volcengine_client import get_resource_client + try: + res_client = get_resource_client( + self.client.ak, self.client.sk + ) + resp = res_client.call("ListProjects", {"Limit": "100"}) + all_projects = [ + p.get("ProjectName", "") for p in + resp.get("Result", {}).get("Projects", []) + ] + except Exception: + all_projects = [] + + if not allowed_projects: + # No projects, deny everything + policy_doc = json.dumps({ + "Statement": [{ + "Effect": "Deny", + "Action": ["ark:*"], + "Resource": ["*"], + }] + }) + else: + # Build explicit deny list: all projects minus allowed ones + deny_projects = [p for p in all_projects if p not in allowed_projects] + if deny_projects: + policy_doc = json.dumps({ + "Statement": [{ + "Effect": "Deny", + "Action": ["ark:*"], + "Resource": [f"trn:iam::*:project/{p}" for p in deny_projects], + }] + }) + else: + # All projects are allowed, no deny needed + # Create a no-op policy + policy_doc = json.dumps({ + "Statement": [{ + "Effect": "Deny", + "Action": ["ark:ThisActionDoesNotExist"], + "Resource": ["*"], + }] + }) + + # Try to update existing, if not found create new + try: + self.client.call("DeletePolicy", {"PolicyName": policy_name}) + except VolcengineAPIError: + pass # Policy doesn't exist yet + + self.client.call("CreatePolicy", { + "PolicyName": policy_name, + "PolicyDocument": policy_doc, + "Description": f"AirGate 自动生成:限制 {username} 只能访问授权项目", + }) + + # Ensure it's attached + try: + self.attach_user_policy(username, policy_name, "Custom") + except VolcengineAPIError as e: + if "PolicyAttachConflict" not in str(e): + raise + + def remove_deny_policy(self, username: str): + """移除子账号的 Deny 策略""" + policy_name = self._deny_policy_name(username) + try: + self.detach_user_policy(username, policy_name, "Custom") + except VolcengineAPIError: + pass + try: + self.client.call("DeletePolicy", {"PolicyName": policy_name}) + except VolcengineAPIError: + pass + def disable_user(self, username: str): """完全停用用户:停控制台 + 停所有 AccessKey""" errors = [] diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index 11d5267..181ee96 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -1232,19 +1232,62 @@ PUT /api/v1/iam-users/{id}/projects/{pid}/policies/ # 更新项目级授权 **结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目",必须在应用层(AirGate)控制。 +### 13.1.1 跨项目 API 访问问题(2026-03-28 实测) + +| 测试场景 | 结果 | +|----------|------| +| 项目级 `ArkFullAccess` 后,用 AK/SK 调 `ListApiKeys` 指定其他项目 | **能看到**其他项目的 API Key(脱敏) | +| 项目级 `ArkFullAccess` 后,用 AK/SK 调 `ListAssetGroups` 指定其他项目 | **能看到**其他项目的全部素材组 | + +**关键发现**:项目级授权(`AttachUserPolicy` + `ProjectName`)只限制了火山控制台的视图,**API 层面的 `ListApiKeys`、`ListAssetGroups` 等查询接口不受项目级权限约束**。子账号用 AK/SK 可以跨项目查询甚至操作其他项目的方舟资源。 + +**解决方案**:为每个子账号创建自定义 Deny 策略,使用 `NotResource` 明确限定只能访问授权项目: + +```json +{ + "Statement": [{ + "Effect": "Deny", + "Action": ["ark:*"], + "NotResource": [ + "trn:iam::*:project/HAGOOT_DEV" + ] + }] +} +``` + +- `NotResource` 表示"除了列出的项目外,其他全部 Deny" +- Deny 优先级高于 Allow,确保跨项目访问被完全阻断 +- AirGate 在添加/移除关联项目时自动更新此 Deny 策略的 `NotResource` 列表 +- 策略命名规则:`AirGate_Deny_{username}`,每个子账号一个 + +**实测验证(2026-03-28)**: + +| 测试 | 无 Deny 策略 | 有 Deny 策略 | +|------|-------------|-------------| +| `ListAssetGroups` 指定 `int_dev_Airlabs` | 返回 79 个素材组 | **被拒绝** ✅ | +| `ListApiKeys` 指定 `int_dev_Airlabs` | 返回 1 个 Key | **被拒绝** ✅ | +| `ListAssetGroups` 指定 `HAGOOT_DEV` | 正常返回 | 正常返回 ✅ | + ### 13.2 最终权限方案 ``` 子账号在火山引擎上的权限(由 AirGate 自动管理): -全局权限:无(不需要任何全局策略) - 或仅保留 AccessKeySelfManageAccess(如果需要) +全局权限: + ├── AccessKeySelfManageAccess ← 管理自己的 AK/SK(可选) + └── AirGate_Deny_{username} ← 自定义 Deny 策略,禁止访问非授权项目 + 使用 NotResource 限定只能访问已关联的项目 项目级权限(通过 AttachUserPolicy + ProjectName): ├── ArkFullAccess ← API 层面有完整方舟操作权限 - └── TOSFullAccess ← API 层面有 TOS 操作权限 + └── TOSFullAccess ← API 层面有 TOS 操作权限(按需) -火山控制台登录:不开通(不给密码 / 停用 LoginProfile) +火山控制台登录:默认关闭(AirGate 提供开关可随时切换) + +Deny 策略自动管理: + - 添加关联项目时 → 自动将项目加入 NotResource 白名单 + - 移除关联项目时 → 自动将项目从 NotResource 白名单移除 + - 策略命名:AirGate_Deny_{username} ``` 子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成: From ff0d0de8f8dbdf78df609326a666c09b1789cf35 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 20:34:19 +0800 Subject: [PATCH 16/40] fix: deny policy audit - detach before delete, fail on empty project list - Fix: detach policy before deleting (avoids deletion error on referenced policy) - Fix: fail explicitly if project list can't be fetched (prevent no-op Deny) - Add _refresh_all_deny_policies helper for batch refresh after new project creation Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 8 ++++++++ backend/utils/iam_service.py | 37 ++++++++++++++++++----------------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index a062c5f..3799f00 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -59,6 +59,14 @@ def _update_deny_policy(user): logger.error(f"更新 Deny 策略失败 ({user.username}): {e}") +def _refresh_all_deny_policies(): + """刷新所有子账号的 Deny 策略(新建火山项目后调用)""" + users = IAMUser.objects.filter(status=IAMUser.Status.ACTIVE) + for user in users: + if user.projects.exists(): + _update_deny_policy(user) + + # ==================== Dashboard ==================== @api_view(['GET']) diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index f5c877e..f2858f1 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -122,17 +122,18 @@ class IAMService: # Get all projects to build explicit deny list from .volcengine_client import get_resource_client - try: - res_client = get_resource_client( - self.client.ak, self.client.sk - ) - resp = res_client.call("ListProjects", {"Limit": "100"}) - all_projects = [ - p.get("ProjectName", "") for p in - resp.get("Result", {}).get("Projects", []) - ] - except Exception: - all_projects = [] + res_client = get_resource_client( + self.client.ak, self.client.sk + ) + resp = res_client.call("ListProjects", {"Limit": "100"}) + all_projects = [ + p.get("ProjectName", "") for p in + resp.get("Result", {}).get("Projects", []) + ] + + if not all_projects: + logger.warning(f"无法获取项目列表,跳过 Deny 策略更新 ({username})") + return if not allowed_projects: # No projects, deny everything @@ -165,7 +166,12 @@ class IAMService: }] }) - # Try to update existing, if not found create new + # Delete old policy (must detach first), then recreate + try: + self.detach_user_policy(username, policy_name, "Custom") + except VolcengineAPIError: + pass # Not attached or doesn't exist + try: self.client.call("DeletePolicy", {"PolicyName": policy_name}) except VolcengineAPIError: @@ -177,12 +183,7 @@ class IAMService: "Description": f"AirGate 自动生成:限制 {username} 只能访问授权项目", }) - # Ensure it's attached - try: - self.attach_user_policy(username, policy_name, "Custom") - except VolcengineAPIError as e: - if "PolicyAttachConflict" not in str(e): - raise + self.attach_user_policy(username, policy_name, "Custom") def remove_deny_policy(self, username: str): """移除子账号的 Deny 策略""" From 92172c6ec895092c7ff6517d6f43a35ffbbdb09f Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 21:15:05 +0800 Subject: [PATCH 17/40] fix: handle missing LoginProfile in disable/enable/edit - Skip LoginProfile operations when user has no console password - Only send non-empty fields to Volcengine UpdateUser API - Fixes enable_user crash for users created without password Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 7 ++++--- backend/utils/iam_service.py | 28 ++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 3799f00..34db3f3 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -467,11 +467,12 @@ def iam_user_edit_profile_view(request, pk): sk = decrypt(account.secret_key_enc) iam = IAMService(ak, sk) + # Only pass non-empty values to Volcengine (empty strings are rejected) try: iam.update_user(user.username, - display_name=display_name, - email=email, - phone=phone) + display_name=display_name if display_name else None, + email=email if email else None, + phone=phone if phone else None) except VolcengineAPIError as e: return Response({'message': f'火山 API 更新失败: {e}'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index f2858f1..3bb63d2 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -197,14 +197,25 @@ class IAMService: except VolcengineAPIError: pass + def _has_login_profile(self, username: str) -> bool: + """检查用户是否有 LoginProfile""" + try: + self.get_login_profile(username) + return True + except VolcengineAPIError as e: + if "LoginProfileNotExist" in str(e) or "RecordNotFound" in str(e): + return False + raise + def disable_user(self, username: str): """完全停用用户:停控制台 + 停所有 AccessKey""" errors = [] - try: - self.update_login_allowed(username, False) - except VolcengineAPIError as e: - errors.append(f"停用控制台失败: {e}") + if self._has_login_profile(username): + try: + self.update_login_allowed(username, False) + except VolcengineAPIError as e: + errors.append(f"停用控制台失败: {e}") try: keys = self.list_access_keys(username) @@ -221,10 +232,11 @@ class IAMService: """恢复用户:恢复控制台 + 恢复所有 AccessKey""" errors = [] - try: - self.update_login_allowed(username, True) - except VolcengineAPIError as e: - errors.append(f"恢复控制台失败: {e}") + if self._has_login_profile(username): + try: + self.update_login_allowed(username, True) + except VolcengineAPIError as e: + errors.append(f"恢复控制台失败: {e}") try: keys = self.list_access_keys(username) From 40655d63e09a4dc3158d68952dadf1a23d40c1de Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 21:40:06 +0800 Subject: [PATCH 18/40] fix: detect ghost LoginProfile from Volcengine (CreateDate=1970) Users created without console password have a phantom LoginProfile that GetLoginProfile returns but UpdateLoginProfile/DeleteLoginProfile reject. Now checking CreateDate to detect this. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/utils/iam_service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index 3bb63d2..4b4f03f 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -198,9 +198,14 @@ class IAMService: pass def _has_login_profile(self, username: str) -> bool: - """检查用户是否有 LoginProfile""" + """检查用户是否有真实的 LoginProfile(火山可能返回空壳)""" try: - self.get_login_profile(username) + resp = self.get_login_profile(username) + profile = resp.get("Result", {}).get("LoginProfile", {}) + # Empty shell has CreateDate=19700101 and Password="" + create_date = profile.get("CreateDate", "") + if create_date.startswith("1970") or create_date.startswith("0001"): + return False return True except VolcengineAPIError as e: if "LoginProfileNotExist" in str(e) or "RecordNotFound" in str(e): From 9cfd5504858a239103d0331ef198d2e6b7e19207 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 21:59:39 +0800 Subject: [PATCH 19/40] fix: sync separates account status from console login status - Account status now comes from Volcengine User.Status field (active/disabled) - Console login status synced to volc_login_allowed separately - Fixes: closing Volcengine login no longer marks account as disabled after sync - Handles ghost LoginProfile (CreateDate=1970) correctly during sync Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 34db3f3..2a16e17 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -213,13 +213,26 @@ def iam_user_sync_view(request): except Exception: pass - # Sync login status + # Sync account status from Volcengine user status (not login profile) + volc_status = u.get("Status", "active") + if volc_status == "active": + obj.status = IAMUser.Status.ACTIVE + elif volc_status == "disabled": + obj.status = IAMUser.Status.DISABLED + else: + obj.status = IAMUser.Status.UNKNOWN + + # Sync volc login status separately try: profile = svc.get_login_profile(username) - login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True) - obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED + lp = profile.get("Result", {}).get("LoginProfile", {}) + create_date = lp.get("CreateDate", "") + if create_date.startswith("1970") or create_date.startswith("0001"): + obj.volc_login_allowed = False + else: + obj.volc_login_allowed = lp.get("LoginAllowed", False) except Exception: - obj.status = IAMUser.Status.UNKNOWN + obj.volc_login_allowed = False obj.save() From bae68ea6a1df25f8e29c2440f5ed3009aa686aa8 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:09:14 +0800 Subject: [PATCH 20/40] fix: sync disable/enable state correctly - Disable: sync volc_login_allowed=False - Enable: sync volc_login_allowed from actual LoginProfile state - Sync: check AK status to detect AirGate-disabled accounts (all AKs inactive = disabled, even if user Status=active) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 2a16e17..94a112a 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -213,14 +213,20 @@ def iam_user_sync_view(request): except Exception: pass - # Sync account status from Volcengine user status (not login profile) + # Sync account status: check both user status and AK status volc_status = u.get("Status", "active") - if volc_status == "active": - obj.status = IAMUser.Status.ACTIVE - elif volc_status == "disabled": + if volc_status != "active": obj.status = IAMUser.Status.DISABLED else: - obj.status = IAMUser.Status.UNKNOWN + # User is active, but check if all AKs are inactive (stopped by AirGate) + all_inactive = False + try: + keys = svc.list_access_keys(username) + if keys and all(k.get("Status") == "inactive" for k in keys): + all_inactive = True + except Exception: + pass + obj.status = IAMUser.Status.DISABLED if all_inactive else IAMUser.Status.ACTIVE # Sync volc login status separately try: @@ -579,7 +585,8 @@ def iam_user_disable_view(request, pk): user.status = IAMUser.Status.DISABLED user.saved_policies_on_disable = saved_policies - user.save(update_fields=['status', 'saved_policies_on_disable']) + user.volc_login_allowed = False + user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed']) policy_count = len(saved_policies) error_info = f",移除失败: {detach_errors}" if detach_errors else "" @@ -629,7 +636,8 @@ def iam_user_enable_view(request, pk): user.status = IAMUser.Status.ACTIVE user.saved_policies_on_disable = [] - user.save(update_fields=['status', 'saved_policies_on_disable']) + user.volc_login_allowed = svc._has_login_profile(user.username) + user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed']) error_info = f",恢复失败: {restore_errors}" if restore_errors else "" AlertRecord.objects.create( From 413977361a74606c4f75d8e93167d71e8065bcd3 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:11:48 +0800 Subject: [PATCH 21/40] fix: restore preserves pre-disable Volcengine login state - Save volc_login_allowed state before disable - Restore to original state (not always open) - e.g. login=off before disable -> still off after restore Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 21 ++++++++++++++++----- backend/utils/iam_service.py | 6 +++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 94a112a..54e337b 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -584,6 +584,8 @@ def iam_user_disable_view(request, pk): pass user.status = IAMUser.Status.DISABLED + # 在策略快照里记住停用前的火山登录状态 + saved_policies.append({"_volc_login_was": user.volc_login_allowed}) user.saved_policies_on_disable = saved_policies user.volc_login_allowed = False user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed']) @@ -620,14 +622,23 @@ def iam_user_enable_view(request, pk): svc = IAMService(ak, sk) try: - # 1. 恢复控制台 + API 密钥 - svc.enable_user(user.username) + # 从快照中提取停用前的火山登录状态 + saved_policies = user.saved_policies_on_disable or [] + restore_login = False + actual_policies = [] + for p in saved_policies: + if "_volc_login_was" in p: + restore_login = p["_volc_login_was"] + else: + actual_policies.append(p) + + # 1. 恢复 API 密钥 + 控制台(按停用前状态) + svc.enable_user(user.username, restore_login=restore_login) # 2. 重新附加停用时保存的策略 restored_count = 0 restore_errors = [] - saved_policies = user.saved_policies_on_disable or [] - for p in saved_policies: + for p in actual_policies: try: svc.attach_user_policy(user.username, p["name"], p["type"]) restored_count += 1 @@ -636,7 +647,7 @@ def iam_user_enable_view(request, pk): user.status = IAMUser.Status.ACTIVE user.saved_policies_on_disable = [] - user.volc_login_allowed = svc._has_login_profile(user.username) + user.volc_login_allowed = restore_login user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed']) error_info = f",恢复失败: {restore_errors}" if restore_errors else "" diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index 4b4f03f..ae17a00 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -233,11 +233,11 @@ class IAMService: if errors: raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors)) - def enable_user(self, username: str): - """恢复用户:恢复控制台 + 恢复所有 AccessKey""" + def enable_user(self, username: str, restore_login: bool = True): + """恢复用户:恢复控制台(可选) + 恢复所有 AccessKey""" errors = [] - if self._has_login_profile(username): + if restore_login and self._has_login_profile(username): try: self.update_login_allowed(username, True) except VolcengineAPIError as e: From 4e62f3f32ebe42216f32982b66fbe68387a69c32 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:15:19 +0800 Subject: [PATCH 22/40] fix: rebuild Deny policy on account restore Deny policy (AirGate_Deny_{username}) was removed during disable but not recreated on restore. Now _update_deny_policy is called during restore to rebuild project isolation. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 54e337b..feeb4c8 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -645,6 +645,9 @@ def iam_user_enable_view(request, pk): except VolcengineAPIError as restore_err: restore_errors.append(f"{p['name']}: {restore_err}") + # 3. 重建 Deny 策略(项目隔离) + _update_deny_policy(user) + user.status = IAMUser.Status.ACTIVE user.saved_policies_on_disable = [] user.volc_login_allowed = restore_login From a2a822a889b42d1fe5576a2b5d30a20e5fbdba28 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:25:29 +0800 Subject: [PATCH 23/40] ui: clarify global vs project-level policies in frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "权限策略" to "全局权限策略" in dropdown menu - Add info banner explaining global vs project-level in both dialogs - Disable already-attached policies in global dropdown (grey out) - Show policy type as tag (系统/自定义) in global policies table Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/views/iam/IAMUserList.vue | 52 ++++++++++++++++++-------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 383bc6f..070e43e 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -82,7 +82,7 @@ {{ row.volc_login_allowed ? '🔓 关闭火山登录' : '🔒 开启火山登录' }} - 权限策略 + 全局权限策略 划拨记录 登录密码 -

- 子账号 {{ projectsUser?.username }} 在此项目下的权限: -

+ + +
方舟/Seedance 完整权限 @@ -282,25 +284,33 @@ - - + + + + +
- - - - - - + 附加
- - - + + + + + + + + + From fab4765e90706b6852b14cbba1c5edfa1dfba69f Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:47:59 +0800 Subject: [PATCH 28/40] fix: remove duplicate @api_view decorator on policies_overview_view Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 4062d74..ca84d2b 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -694,7 +694,6 @@ def iam_user_enable_view(request, pk): status=status.HTTP_502_BAD_GATEWAY) -@api_view(['GET']) @api_view(['GET']) def iam_user_policies_overview_view(request, pk): """查看子账号的完整权限总览(全局 + 各项目)""" From dacc521c1c0a9d2684a6e15f3335b3b6e054c079 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 28 Mar 2026 22:53:18 +0800 Subject: [PATCH 29/40] feat: integrate project management into unified policy page - Add project section: add/remove projects with policy selection - Each project card shows: policies, spending, monitor toggle, remove - Replaces separate project management dialog - All project and policy operations on one page Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/monitor/views.py | 4 + frontend/src/views/iam/UserPoliciesView.vue | 102 ++++++++++++++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index ca84d2b..4de3956 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -748,6 +748,8 @@ def iam_user_policies_overview_view(request, pk): 'project_name': proj.project_name, 'display_name': proj.display_name, 'project_id': proj.id, + 'monitor_enabled': proj.monitor_enabled, + 'current_spending': str(proj.current_spending), 'policies': proj_items, }) except Exception: @@ -755,6 +757,8 @@ def iam_user_policies_overview_view(request, pk): 'project_name': proj.project_name, 'display_name': proj.display_name, 'project_id': proj.id, + 'monitor_enabled': proj.monitor_enabled, + 'current_spending': str(proj.current_spending), 'policies': [], }) diff --git a/frontend/src/views/iam/UserPoliciesView.vue b/frontend/src/views/iam/UserPoliciesView.vue index af860ff..b63aeb6 100644 --- a/frontend/src/views/iam/UserPoliciesView.vue +++ b/frontend/src/views/iam/UserPoliciesView.vue @@ -1,7 +1,7 @@