Compare commits

..

40 Commits

Author SHA1 Message Date
seaislee1209
236e082349 ui: show project names in IAMUserList instead of count
Display project name tags (green=monitoring, grey=not) with a
'manage' link to the policy page. Much more informative at a glance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:09:57 +08:00
seaislee1209
9e81717e08 feat: switch feishu alerts from Webhook to App (private message)
- Replace Webhook with App ID + App Secret + mobile number
- Reuse AirDrama's feishu app (send private card messages)
- Add test button in system settings
- Add test-feishu API endpoint
- Default monitor interval changed to 60 seconds
- Token caching for feishu tenant_access_token

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:56:01 +08:00
seaislee1209
6b3a0bda34 fix: sync removes users that no longer exist on Volcengine
- Track all Volcengine usernames during sync
- Delete local users not found on Volcengine (cascade deletes related data)
- Report removed users in sync response
- Deleted test_audit and tudouceshi from local DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:45:14 +08:00
seaislee1209
610058ae5f feat: switch billing to ListSplitBillDetail for accurate project spending
- BillingService now uses ListSplitBillDetail (split bill) instead of
  ListBillDetail (bill detail) - the latter shows Project='-' for
  Seedance pay-as-you-go products
- Added get_spending_all_projects() for batch query (avoids N+1 API calls)
- Scheduler optimized: single API call fetches all project spending
- Verified: amounts match Volcengine console split bill page exactly
- Updated research report with billing API findings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:42:08 +08:00
seaislee1209
294a0885ff docs: update operation guide, changelog, and research report
- Rewrite 操作说明.md with full admin/sub-account operation flow
- Add v0.5.0 to 版本管理.md (permission refactor + account enhancements)
- Add Scope=Project API limitation finding to research report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 02:06:00 +08:00
seaislee1209
9f00e6996b feat: add Deny policy exempt toggle for admin sub-accounts
- New deny_policy_exempt field on IAMUser model
- Toggle in monitoring config dialog
- Exempt accounts skip Deny policy creation
- Changing the toggle immediately updates/removes Deny policy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:01:35 +08:00
seaislee1209
5b997bc1a7 fix: restore volcProjects for create dialog (was removed during cleanup)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:44:34 +08:00
seaislee1209
6f4d7e6b5b fix: refresh ALL users' Deny policies on project changes
When a project is added/removed for any user, all users' Deny
policies must be updated - new projects need to be added to other
users' deny lists to prevent unauthorized cross-project access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:29:54 +08:00
seaislee1209
d7b40beff7 fix: Volcengine API does not support project-level policy scope
AttachUserPolicy ignores Scope=Project parameter - policies always
attach globally. Project isolation now relies entirely on Deny policy
(AirGate_Deny_{username}) which blocks access to non-whitelisted projects.

Updated report with this finding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:21:08 +08:00
seaislee1209
765c80a47a fix: project policy update compares against Volcengine actual state
Was comparing against local DB which could be stale.
Now queries Volcengine for actual project-level policies before diffing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:10:13 +08:00
seaislee1209
c4c6a03f61 refactor: remove old project/policy dialogs from IAMUserList
All project management and policy operations now handled in the
unified UserPoliciesView page. Removed unused dialogs, variables,
and functions from IAMUserList.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:07:16 +08:00
seaislee1209
dacc521c1c 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) <noreply@anthropic.com>
2026-03-28 22:53:18 +08:00
seaislee1209
fab4765e90 fix: remove duplicate @api_view decorator on policies_overview_view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:47:59 +08:00
seaislee1209
4b8181b96a feat: unified policy overview page for sub-accounts
- New page: /iam-users/:id/policies shows all policies in one view
- Separated into global policies and per-project policies sections
- Each section has inline add/remove with disabled duplicates
- Backend: new policies/overview/ endpoint returns global + project policies
- Replaces old popup-based policy management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:45:04 +08:00
seaislee1209
9ffa13f44d fix: separate global and project-level policies in frontend display
- Global policy view: filter out project-scoped policies, only show Global
- Project list view: filter out global policies, only show Project-scoped
- Fixes: same policy appearing in both global and project views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:41:35 +08:00
seaislee1209
d0d48ceb19 fix: add Scope=Project to project-level policy attach/detach
Without Scope parameter, AttachUserPolicy defaults to Global scope
even when ProjectName is provided. Adding Scope=Project ensures
policies are correctly limited to the specified project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:39:24 +08:00
seaislee1209
23ec78e83d fix: save/restore policies with correct scope (global vs project-level)
- Disable now saves both global and project-level policies with scope info
- Restore puts policies back in original scope (global or project)
- Project list view now syncs policies from Volcengine in real-time
- Fixes: policies incorrectly restored as global after disable/enable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:31:24 +08:00
seaislee1209
a2a822a889 ui: clarify global vs project-level policies in frontend
- 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) <noreply@anthropic.com>
2026-03-28 22:25:29 +08:00
seaislee1209
4e62f3f32e 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) <noreply@anthropic.com>
2026-03-28 22:15:19 +08:00
seaislee1209
413977361a 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) <noreply@anthropic.com>
2026-03-28 22:11:48 +08:00
seaislee1209
bae68ea6a1 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) <noreply@anthropic.com>
2026-03-28 22:09:14 +08:00
seaislee1209
9cfd550485 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) <noreply@anthropic.com>
2026-03-28 21:59:39 +08:00
seaislee1209
40655d63e0 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) <noreply@anthropic.com>
2026-03-28 21:40:06 +08:00
seaislee1209
92172c6ec8 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) <noreply@anthropic.com>
2026-03-28 21:15:05 +08:00
seaislee1209
ff0d0de8f8 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) <noreply@anthropic.com>
2026-03-28 20:34:19 +08:00
seaislee1209
0f034b7b26 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) <noreply@anthropic.com>
2026-03-28 20:30:56 +08:00
seaislee1209
48c55765c8 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) <noreply@anthropic.com>
2026-03-28 19:38:39 +08:00
seaislee1209
f79ae0084d 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) <noreply@anthropic.com>
2026-03-28 19:27:36 +08:00
seaislee1209
8b49d49048 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) <noreply@anthropic.com>
2026-03-28 15:57:02 +08:00
seaislee1209
b25641cfc6 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) <noreply@anthropic.com>
2026-03-21 17:51:45 +08:00
seaislee1209
fac5e1b541 feat: password management for admin and sub-accounts
- 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) <noreply@anthropic.com>
2026-03-21 15:54:35 +08:00
seaislee1209
33c8963d46 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) <noreply@anthropic.com>
2026-03-21 01:35:35 +08:00
seaislee1209
3d2b332657 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) <noreply@anthropic.com>
2026-03-21 01:33:02 +08:00
seaislee1209
daa82aee76 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) <noreply@anthropic.com>
2026-03-21 01:30:35 +08:00
seaislee1209
7feb007f57 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) <noreply@anthropic.com>
2026-03-21 01:25:12 +08:00
seaislee1209
0ac2ef1f27 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) <noreply@anthropic.com>
2026-03-20 21:36:13 +08:00
seaislee1209
8e564ed640 docs: major report update - AirGate as sole entry point for sub-accounts
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) <noreply@anthropic.com>
2026-03-20 20:36:07 +08:00
seaislee1209
314612f454 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) <noreply@anthropic.com>
2026-03-20 19:52:34 +08:00
seaislee1209
c58fe56d89 feat: add project-level policy management (add/remove per project)
- 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) <noreply@anthropic.com>
2026-03-20 19:28:14 +08:00
seaislee1209
6dd3ac5c0d 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) <noreply@anthropic.com>
2026-03-20 19:20:07 +08:00
37 changed files with 2898 additions and 507 deletions

33
API方案.md Normal file
View File

@ -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 变更你得跟着维护
一句话结论
队友是自己人 → 方案一,省事;对外卖服务/不信任对方 → 方案二,可控。

View File

@ -6,16 +6,12 @@ ENV GUNICORN_RUNNING=1
WORKDIR /app WORKDIR /app
# System dependencies (Aliyun mirror for China) RUN apt-get update && apt-get install -y --no-install-recommends \
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \ gcc default-libmysqlclient-dev pkg-config \
apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ RUN pip install --no-cache-dir -r requirements.txt
pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .

View File

@ -13,6 +13,10 @@ class UserInfoSerializer(serializers.Serializer):
is_active = serializers.BooleanField() is_active = serializers.BooleanField()
date_joined = serializers.DateTimeField() date_joined = serializers.DateTimeField()
last_login = serializers.DateTimeField() last_login = serializers.DateTimeField()
role = serializers.SerializerMethodField()
def get_role(self, obj):
return 'admin'
class ChangePasswordSerializer(serializers.Serializer): class ChangePasswordSerializer(serializers.Serializer):

View File

@ -10,4 +10,11 @@ urlpatterns = [
path('admins/create/', views.admin_create_view), path('admins/create/', views.admin_create_view),
path('admins/<int:pk>/toggle/', views.admin_toggle_view), path('admins/<int:pk>/toggle/', views.admin_toggle_view),
path('admins/<int:pk>/reset-password/', views.admin_reset_password_view), path('admins/<int:pk>/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/<int:pk>/reveal/', views.iam_my_key_reveal_view),
path('iam/change-password/', views.iam_change_password_view),
] ]

View File

@ -1,6 +1,6 @@
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from rest_framework import status 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.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
@ -195,3 +195,214 @@ def admin_reset_password_view(request, pk):
) )
return Response({'message': f'已重置 {user.username} 的密码'}) 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,
})
@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': '密码修改成功,请重新登录'})

View File

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

View File

@ -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 登录密码哈希'),
),
]

View File

@ -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='允许登录火山控制台'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.21 on 2026-03-28 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('monitor', '0009_iamuser_volc_login_allowed'),
]
operations = [
migrations.AddField(
model_name='iamuser',
name='deny_policy_exempt',
field=models.BooleanField(default=False, help_text='开启后不生成项目隔离 Deny 策略(适用于管理员自用账号)', verbose_name='免除 Deny 策略'),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.21 on 2026-03-29 13:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('monitor', '0010_iamuser_deny_policy_exempt'),
]
operations = [
migrations.AddField(
model_name='globalconfig',
name='feishu_app_id',
field=models.CharField(blank=True, max_length=200, verbose_name='飞书 App ID'),
),
migrations.AddField(
model_name='globalconfig',
name='feishu_app_secret',
field=models.CharField(blank=True, max_length=200, verbose_name='飞书 App Secret'),
),
migrations.AlterField(
model_name='globalconfig',
name='feishu_alert_mobiles',
field=models.CharField(blank=True, help_text='接收告警的飞书用户手机号,多个用逗号分隔', max_length=500, verbose_name='飞书通知手机号(逗号分隔)'),
),
migrations.AlterField(
model_name='globalconfig',
name='feishu_webhook_url',
field=models.URLField(blank=True, max_length=500, verbose_name='飞书 Webhook URL已弃用'),
),
migrations.AlterField(
model_name='globalconfig',
name='monitor_interval_seconds',
field=models.IntegerField(default=60, verbose_name='监控间隔(秒)'),
),
]

View File

@ -1,4 +1,5 @@
from decimal import Decimal from decimal import Decimal
from django.contrib.auth.hashers import make_password, check_password
from django.db import models from django.db import models
@ -37,6 +38,11 @@ class IAMUser(models.Model):
phone = models.CharField('手机号', max_length=20, blank=True) phone = models.CharField('手机号', max_length=20, blank=True)
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN) 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)
volc_login_allowed = models.BooleanField('允许登录火山控制台', default=False)
# Access keys (stored as JSON list of AK IDs, not secrets) # Access keys (stored as JSON list of AK IDs, not secrets)
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
@ -56,6 +62,9 @@ class IAMUser(models.Model):
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True, triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
help_text='记录已通知过的百分比,划拨新额度时自动重置') help_text='记录已通知过的百分比,划拨新额度时自动重置')
deny_policy_exempt = models.BooleanField('免除 Deny 策略', default=False,
help_text='开启后不生成项目隔离 Deny 策略(适用于管理员自用账号)')
# --- 停用时保存的策略快照(恢复时自动加回) --- # --- 停用时保存的策略快照(恢复时自动加回) ---
saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True, saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True,
help_text='停用时自动移除的策略列表,恢复时加回') help_text='停用时自动移除的策略列表,恢复时加回')
@ -73,6 +82,12 @@ class IAMUser(models.Model):
def __str__(self): def __str__(self):
return f"{self.display_name or self.username} ({self.status})" 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 @property
def remaining_quota(self): def remaining_quota(self):
"""剩余额度""" """剩余额度"""
@ -116,6 +131,34 @@ class IAMUserProject(models.Model):
return f"{self.project_name} ({status}) ¥{self.current_spending}" 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): class QuotaAllocation(models.Model):
"""额度划拨记录""" """额度划拨记录"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations') iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
@ -141,9 +184,13 @@ class GlobalConfig(models.Model):
help_text='如 [50, 80, 90]') help_text='如 [50, 80, 90]')
default_project_policies = models.JSONField('添加项目时自动授权的策略', default=list, blank=True, default_project_policies = models.JSONField('添加项目时自动授权的策略', default=list, blank=True,
help_text='如 ["ArkFullAccess", "TOSFullAccess"]') help_text='如 ["ArkFullAccess", "TOSFullAccess"]')
monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600) monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=60)
feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True) feishu_app_id = models.CharField('飞书 App ID', max_length=200, blank=True)
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True) feishu_app_secret = models.CharField('飞书 App Secret', max_length=200, blank=True)
feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True,
help_text='接收告警的飞书用户手机号,多个用逗号分隔')
# 保留 webhook 字段兼容性,但不再使用
feishu_webhook_url = models.URLField('飞书 Webhook URL已弃用', max_length=500, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View File

@ -1,5 +1,5 @@
from rest_framework import serializers 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): class VolcAccountSerializer(serializers.ModelSerializer):
@ -41,6 +41,8 @@ class IAMUserSerializer(serializers.ModelSerializer):
'alert_thresholds', 'triggered_alerts', 'alert_thresholds', 'triggered_alerts',
'effective_alert_thresholds', 'effective_alert_thresholds',
'projects', 'monitored_project_count', 'projects', 'monitored_project_count',
'login_enabled', 'volc_login_allowed',
'deny_policy_exempt',
'remark', 'created_at', 'updated_at', 'remark', 'created_at', 'updated_at',
] ]
read_only_fields = ['user_id', 'access_key_ids', 'status', read_only_fields = ['user_id', 'access_key_ids', 'status',
@ -76,6 +78,7 @@ class IAMUserConfigSerializer(serializers.Serializer):
) )
monitor_enabled = serializers.BooleanField(required=False) monitor_enabled = serializers.BooleanField(required=False)
auto_disable_enabled = serializers.BooleanField(required=False) auto_disable_enabled = serializers.BooleanField(required=False)
deny_policy_exempt = serializers.BooleanField(required=False)
class IAMUserProjectAddSerializer(serializers.Serializer): class IAMUserProjectAddSerializer(serializers.Serializer):
@ -118,7 +121,8 @@ class GlobalConfigSerializer(serializers.ModelSerializer):
'default_alert_thresholds', 'default_alert_thresholds',
'default_project_policies', 'default_project_policies',
'monitor_interval_seconds', 'monitor_interval_seconds',
'feishu_webhook_url', 'feishu_alert_mobiles', 'feishu_app_id', 'feishu_app_secret',
'feishu_alert_mobiles',
'updated_at', 'updated_at',
] ]
read_only_fields = ['updated_at'] read_only_fields = ['updated_at']
@ -135,6 +139,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): class DashboardSerializer(serializers.Serializer):
total_users = serializers.IntegerField() total_users = serializers.IntegerField()
active_users = serializers.IntegerField() active_users = serializers.IntegerField()

View File

@ -17,15 +17,20 @@ urlpatterns = [
path('iam-users/import/', views.iam_user_import_view), path('iam-users/import/', views.iam_user_import_view),
path('iam-users/<int:pk>/', views.iam_user_detail_view), path('iam-users/<int:pk>/', views.iam_user_detail_view),
path('iam-users/<int:pk>/update/', views.iam_user_update_view), path('iam-users/<int:pk>/update/', views.iam_user_update_view),
path('iam-users/<int:pk>/edit-profile/', views.iam_user_edit_profile_view),
path('iam-users/<int:pk>/toggle-volc-login/', views.iam_user_toggle_volc_login_view),
path('iam-users/<int:pk>/set-login/', views.iam_user_set_login_view),
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view), path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view), path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view), path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
path('iam-users/<int:pk>/policies/overview/', views.iam_user_policies_overview_view),
path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view), path('iam-users/<int:pk>/policies/attach/', views.iam_user_attach_policy_view),
path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view), path('iam-users/<int:pk>/policies/detach/', views.iam_user_detach_policy_view),
# IAM user projects (multi-project) # IAM user projects (multi-project)
path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view), path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view),
path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view), path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view),
path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view), path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view),
path('iam-users/<int:pk>/projects/<int:pid>/policies/', views.iam_user_project_policies_view),
path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view), path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view),
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view), path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),
@ -40,10 +45,18 @@ urlpatterns = [
# Global config # Global config
path('config/', views.global_config_view), path('config/', views.global_config_view),
path('config/test-feishu/', views.test_feishu_view),
# Alerts # Alerts
path('alerts/', views.alert_list_view), path('alerts/', views.alert_list_view),
# Projects # Projects
path('projects/', views.project_list_view), path('projects/', views.project_list_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/<int:pk>/', views.ark_key_update_view),
path('ark-keys/<int:pk>/delete/', views.ark_key_delete_view),
path('ark-keys/<int:pk>/reveal/', views.ark_key_reveal_view),
] ]

View File

@ -12,9 +12,10 @@ from rest_framework.response import Response
from utils.crypto import encrypt, decrypt, make_hint from utils.crypto import encrypt, decrypt, make_hint
from utils.iam_service import IAMService, ProjectService from utils.iam_service import IAMService, ProjectService
from utils.billing_service import BillingService from utils.billing_service import BillingService
from utils.ark_service import ArkService
from utils.volcengine_client import VolcengineAPIError 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 ( from .serializers import (
VolcAccountSerializer, VolcAccountCreateSerializer, VolcAccountSerializer, VolcAccountCreateSerializer,
IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer,
@ -23,6 +24,7 @@ from .serializers import (
QuotaAllocateSerializer, QuotaAllocationSerializer, QuotaAllocateSerializer, QuotaAllocationSerializer,
GlobalConfigSerializer, GlobalConfigSerializer,
AlertRecordSerializer, AlertRecordSerializer,
ArkApiKeySerializer, ArkApiKeyCreateSerializer,
DashboardSerializer, DashboardSerializer,
) )
@ -42,6 +44,35 @@ def _get_volc_account(volc_id=None):
return account, ak, sk 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)
if user.deny_policy_exempt:
# 免除 Deny 策略的账号,移除已有的 Deny 策略
svc.remove_deny_policy(user.username)
return
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}")
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 ==================== # ==================== Dashboard ====================
@api_view(['GET']) @api_view(['GET'])
@ -153,6 +184,7 @@ def iam_user_sync_view(request):
svc = IAMService(ak, sk) svc = IAMService(ak, sk)
imported = [] imported = []
volc_usernames = set()
offset = 0 offset = 0
while True: while True:
@ -168,6 +200,7 @@ def iam_user_sync_view(request):
for u in users: for u in users:
username = u.get("UserName", "") username = u.get("UserName", "")
volc_usernames.add(username)
obj, created = IAMUser.objects.update_or_create( obj, created = IAMUser.objects.update_or_create(
volc_account=account, volc_account=account,
username=username, username=username,
@ -188,13 +221,32 @@ def iam_user_sync_view(request):
except Exception: except Exception:
pass pass
# Sync login status # Sync account status: check both user status and AK status
volc_status = u.get("Status", "active")
if volc_status != "active":
obj.status = IAMUser.Status.DISABLED
else:
# 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: try:
profile = svc.get_login_profile(username) profile = svc.get_login_profile(username)
login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True) lp = profile.get("Result", {}).get("LoginProfile", {})
obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED 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: except Exception:
obj.status = IAMUser.Status.UNKNOWN obj.volc_login_allowed = False
obj.save() obj.save()
@ -203,10 +255,22 @@ def iam_user_sync_view(request):
if offset >= total: if offset >= total:
break break
# 删除火山已不存在的用户(本地有但火山没有)
removed = []
local_users = IAMUser.objects.filter(volc_account=account)
for local_user in local_users:
if local_user.username not in volc_usernames:
removed.append(local_user.username)
local_user.delete()
total_count = IAMUser.objects.filter(volc_account=account).count() total_count = IAMUser.objects.filter(volc_account=account).count()
msg = f'同步完成,共 {total_count} 个用户,新导入 {len(imported)}'
if removed:
msg += f',清理 {len(removed)} 个已删除用户({", ".join(removed)}'
return Response({ return Response({
'message': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)}', 'message': msg,
'imported': imported, 'imported': imported,
'removed': removed,
'total': total_count, 'total': total_count,
}) })
@ -250,7 +314,18 @@ def iam_user_create_view(request):
try: try:
svc.create_login_profile(d['username'], password) svc.create_login_profile(d['username'], password)
result_info['login_enabled'] = True result_info['login_enabled'] = True
result_info['volc_login_allowed'] = True
except VolcengineAPIError as e: 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) result_info['login_error'] = str(e)
# 3. Create access key # 3. Create access key
@ -280,6 +355,7 @@ def iam_user_create_view(request):
phone=d.get('phone', ''), phone=d.get('phone', ''),
status=IAMUser.Status.ACTIVE, status=IAMUser.Status.ACTIVE,
access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [], 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 # 6. Auto-add project if specified
@ -291,6 +367,9 @@ def iam_user_create_view(request):
monitor_enabled=True, monitor_enabled=True,
) )
# 7. Create Deny policy (project isolation) + refresh all users
_refresh_all_deny_policies()
AlertRecord.objects.create( AlertRecord.objects.create(
iam_user=obj, iam_user=obj,
alert_type=AlertRecord.AlertType.MANUAL, alert_type=AlertRecord.AlertType.MANUAL,
@ -361,12 +440,142 @@ def iam_user_update_view(request, pk):
serializer = IAMUserConfigSerializer(data=request.data, partial=True) serializer = IAMUserConfigSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
deny_changed = 'deny_policy_exempt' in serializer.validated_data and \
serializer.validated_data['deny_policy_exempt'] != user.deny_policy_exempt
for field, value in serializer.validated_data.items(): for field, value in serializer.validated_data.items():
setattr(user, field, value) setattr(user, field, value)
user.save() user.save()
if deny_changed:
_update_deny_policy(user)
return Response(IAMUserSerializer(user).data) 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):
"""编辑子账号信息(显示名、手机号、邮箱),同步到火山"""
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)
# Only pass non-empty values to Volcengine (empty strings are rejected)
try:
iam.update_user(user.username,
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)
# 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 登录密码"""
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']) @api_view(['POST'])
def iam_user_disable_view(request, pk): def iam_user_disable_view(request, pk):
"""停用子账号""" """停用子账号"""
@ -387,6 +596,8 @@ def iam_user_disable_view(request, pk):
# 2. 移除所有权限策略并保存快照(恢复时加回) # 2. 移除所有权限策略并保存快照(恢复时加回)
saved_policies = [] saved_policies = []
detach_errors = [] detach_errors = []
# 2a. 全局策略
try: try:
resp = svc.list_attached_user_policies(user.username) resp = svc.list_attached_user_policies(user.username)
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
@ -395,15 +606,37 @@ def iam_user_disable_view(request, pk):
ptype = p.get("PolicyType", "") ptype = p.get("PolicyType", "")
try: try:
svc.detach_user_policy(user.username, pname, ptype) svc.detach_user_policy(user.username, pname, ptype)
saved_policies.append({"name": pname, "type": ptype}) saved_policies.append({"name": pname, "type": ptype, "scope": "global"})
except VolcengineAPIError as detach_err: except VolcengineAPIError as detach_err:
detach_errors.append(f"{pname}: {detach_err}") detach_errors.append(f"{pname}(global): {detach_err}")
except VolcengineAPIError:
pass
# 2b. 项目级策略
for proj in user.projects.all():
try:
resp = svc.client.call('ListAttachedUserPolicies', {
'UserName': user.username,
'ProjectName': proj.project_name,
})
proj_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
for p in proj_policies:
pname = p.get("PolicyName", "")
ptype = p.get("PolicyType", "")
try:
svc.detach_policy_in_project(user.username, pname, proj.project_name, ptype)
saved_policies.append({"name": pname, "type": ptype, "scope": "project", "project": proj.project_name})
except VolcengineAPIError as detach_err:
detach_errors.append(f"{pname}({proj.project_name}): {detach_err}")
except VolcengineAPIError: except VolcengineAPIError:
pass pass
user.status = IAMUser.Status.DISABLED user.status = IAMUser.Status.DISABLED
# 在策略快照里记住停用前的火山登录状态
saved_policies.append({"_volc_login_was": user.volc_login_allowed})
user.saved_policies_on_disable = saved_policies 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) policy_count = len(saved_policies)
error_info = f",移除失败: {detach_errors}" if detach_errors else "" error_info = f",移除失败: {detach_errors}" if detach_errors else ""
@ -437,23 +670,39 @@ def iam_user_enable_view(request, pk):
svc = IAMService(ak, sk) svc = IAMService(ak, sk)
try: 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)
# 2. 重新附加停用时保存的策略 # 1. 恢复 API 密钥 + 控制台(按停用前状态)
svc.enable_user(user.username, restore_login=restore_login)
# 2. 重新附加停用时保存的策略(按原始位置:全局或项目级)
restored_count = 0 restored_count = 0
restore_errors = [] restore_errors = []
saved_policies = user.saved_policies_on_disable or [] for p in actual_policies:
for p in saved_policies:
try: try:
if p.get("scope") == "project" and p.get("project"):
svc.attach_policy_in_project(user.username, p["name"], p["project"], p["type"])
else:
svc.attach_user_policy(user.username, p["name"], p["type"]) svc.attach_user_policy(user.username, p["name"], p["type"])
restored_count += 1 restored_count += 1
except VolcengineAPIError as restore_err: except VolcengineAPIError as restore_err:
restore_errors.append(f"{p['name']}: {restore_err}") restore_errors.append(f"{p['name']}: {restore_err}")
# 3. 重建 Deny 策略(项目隔离)
_update_deny_policy(user)
user.status = IAMUser.Status.ACTIVE user.status = IAMUser.Status.ACTIVE
user.saved_policies_on_disable = [] user.saved_policies_on_disable = []
user.save(update_fields=['status', 'saved_policies_on_disable']) 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 "" error_info = f",恢复失败: {restore_errors}" if restore_errors else ""
AlertRecord.objects.create( AlertRecord.objects.create(
@ -473,6 +722,84 @@ def iam_user_enable_view(request, pk):
@api_view(['GET']) @api_view(['GET'])
def iam_user_policies_overview_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, 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)
try:
# Get all policies
resp = svc.list_attached_user_policies(user.username)
all_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
# Separate global vs project
global_policies = []
for p in all_policies:
scopes = p.get('PolicyScope', [])
is_global = not scopes or any(s.get('PolicyScopeType') == 'Global' for s in scopes)
if is_global:
global_policies.append({
'name': p.get('PolicyName', ''),
'type': p.get('PolicyType', ''),
'description': p.get('Description', ''),
})
# Get project-level policies for each associated project
project_policies = []
for proj in user.projects.all():
try:
resp2 = svc.client.call('ListAttachedUserPolicies', {
'UserName': user.username,
'ProjectName': proj.project_name,
})
proj_items = []
for p in resp2.get('Result', {}).get('AttachedPolicyMetadata', []):
scopes = p.get('PolicyScope', [])
for s in scopes:
if s.get('PolicyScopeType') == 'Project' and s.get('ProjectName') == proj.project_name:
proj_items.append({
'name': p.get('PolicyName', ''),
'type': p.get('PolicyType', ''),
'description': p.get('Description', ''),
})
break
project_policies.append({
'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:
project_policies.append({
'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': [],
})
return Response({
'username': user.username,
'display_name': user.display_name,
'global_policies': global_policies,
'project_policies': project_policies,
})
except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY)
def iam_user_policies_view(request, pk): def iam_user_policies_view(request, pk):
"""查看子账号的权限策略""" """查看子账号的权限策略"""
try: try:
@ -487,8 +814,15 @@ def iam_user_policies_view(request, pk):
svc = IAMService(ak, sk) svc = IAMService(ak, sk)
try: try:
resp = svc.list_attached_user_policies(user.username) resp = svc.list_attached_user_policies(user.username)
policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) all_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", [])
return Response({'policies': policies}) # 只返回全局策略(过滤项目级的)
global_policies = []
for p in all_policies:
scopes = p.get('PolicyScope', [])
is_global = not scopes or any(s.get('PolicyScopeType') == 'Global' for s in scopes)
if is_global:
global_policies.append(p)
return Response({'policies': global_policies})
except VolcengineAPIError as e: except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)}, return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY) status=status.HTTP_502_BAD_GATEWAY)
@ -564,12 +898,38 @@ def iam_user_detach_policy_view(request, pk):
@api_view(['GET']) @api_view(['GET'])
def iam_user_project_list_view(request, pk): def iam_user_project_list_view(request, pk):
"""查看子账号关联的项目列表""" """查看子账号关联的项目列表(实时从火山同步项目级策略)"""
try: try:
user = IAMUser.objects.get(pk=pk) user = IAMUser.objects.get(pk=pk)
except IAMUser.DoesNotExist: except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
projects = user.projects.all() projects = user.projects.all()
# 实时从火山查询每个项目的策略,同步到本地(只取项目级的,过滤全局的)
account, ak, sk = _get_volc_account(user.volc_account_id)
if ak:
svc = IAMService(ak, sk)
for proj in projects:
try:
resp = svc.client.call('ListAttachedUserPolicies', {
'UserName': user.username,
'ProjectName': proj.project_name,
})
# 只保留 PolicyScopeType=Project 的策略,过滤掉全局的
volc_policies = []
for p in resp.get('Result', {}).get('AttachedPolicyMetadata', []):
scopes = p.get('PolicyScope', [])
for s in scopes:
if s.get('PolicyScopeType') == 'Project' and s.get('ProjectName') == proj.project_name:
volc_policies.append(p.get('PolicyName', ''))
break
if set(volc_policies) != set(proj.attached_policies or []):
proj.attached_policies = volc_policies
proj.save(update_fields=['attached_policies'])
except Exception:
pass
return Response(IAMUserProjectSerializer(projects, many=True).data) return Response(IAMUserProjectSerializer(projects, many=True).data)
@ -615,6 +975,9 @@ def iam_user_project_add_view(request, pk):
obj.attached_policies = attached obj.attached_policies = attached
obj.save(update_fields=['attached_policies']) obj.save(update_fields=['attached_policies'])
# 更新所有子账号的 Deny 策略(新项目需要加入其他人的拒绝列表)
_refresh_all_deny_policies()
AlertRecord.objects.create( AlertRecord.objects.create(
iam_user=user, iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL, alert_type=AlertRecord.AlertType.MANUAL,
@ -648,6 +1011,82 @@ def iam_user_project_update_view(request, pk, pid):
return Response(IAMUserProjectSerializer(project).data) 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', [])
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)
# Get actual current policies from Volcengine (not local DB)
actual_old = []
try:
resp = svc.client.call('ListAttachedUserPolicies', {
'UserName': user.username,
'ProjectName': project.project_name,
})
for p in resp.get('Result', {}).get('AttachedPolicyMetadata', []):
scopes = p.get('PolicyScope', [])
for s in scopes:
if s.get('PolicyScopeType') == 'Project' and s.get('ProjectName') == project.project_name:
actual_old.append(p.get('PolicyName', ''))
break
except Exception:
actual_old = project.attached_policies or []
attached = []
detached = []
errors = []
# Remove policies that were removed
to_remove = [p for p in actual_old 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 actual_old]
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']) @api_view(['DELETE'])
def iam_user_project_delete_view(request, pk, pid): def iam_user_project_delete_view(request, pk, pid):
"""移除关联项目:回收权限 + 移出监测""" """移除关联项目:回收权限 + 移出监测"""
@ -682,6 +1121,9 @@ def iam_user_project_delete_view(request, pk, pid):
project.delete() project.delete()
# 更新所有子账号的 Deny 策略
_refresh_all_deny_policies()
result = {'message': f'已移除项目 {name},已回收权限: {detached}'} result = {'message': f'已移除项目 {name},已回收权限: {detached}'}
if detach_errors: if detach_errors:
result['detach_errors'] = detach_errors result['detach_errors'] = detach_errors
@ -825,6 +1267,28 @@ def global_config_view(request):
return Response(serializer.data) return Response(serializer.data)
@api_view(['POST'])
def test_feishu_view(request):
"""测试飞书通知"""
config = GlobalConfig.get_solo()
app_id = config.feishu_app_id
app_secret = config.feishu_app_secret
mobile = request.data.get('mobile', '') or (config.feishu_alert_mobiles or '').split(',')[0].strip()
if not app_id or not app_secret:
return Response({'message': '请先配置飞书 App ID 和 App Secret'},
status=status.HTTP_400_BAD_REQUEST)
if not mobile:
return Response({'message': '请填写接收人手机号'},
status=status.HTTP_400_BAD_REQUEST)
from utils.feishu import send_feishu_test
success, msg = send_feishu_test(app_id, app_secret, mobile)
if success:
return Response({'message': msg})
return Response({'message': msg}, status=status.HTTP_400_BAD_REQUEST)
# ==================== Alerts ==================== # ==================== Alerts ====================
@api_view(['GET']) @api_view(['GET'])
@ -861,3 +1325,128 @@ def project_list_view(request):
except VolcengineAPIError as e: except VolcengineAPIError as e:
return Response({'error': 'api_error', 'message': str(e)}, return Response({'error': 'api_error', 'message': str(e)},
status=status.HTTP_502_BAD_GATEWAY) status=status.HTTP_502_BAD_GATEWAY)
# ==================== Ark API Key Management (手动录入模式) ====================
@api_view(['GET'])
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):
"""录入 API Key管理员操作"""
serializer = ArkApiKeyCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
d = serializer.validated_data
try:
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(['PUT'])
def ark_key_update_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)
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: {obj.key_name}",
content=f"操作人: {request.user.username}",
)
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, 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},项目: {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,
})

View File

@ -0,0 +1,42 @@
"""方舟ArkAPI 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,
})

View File

@ -8,16 +8,21 @@ logger = logging.getLogger(__name__)
class BillingService: class BillingService:
"""封装火山引擎 Billing API""" """封装火山引擎 Billing API
使用 ListSplitBillDetail分账账单而非 ListBillDetail账单明细
因为后者的 Project 字段对 Seedance 等按量付费产品显示为 '-'不准确
分账账单能正确按项目归属消费与火山控制台分账账单页面一致
"""
def __init__(self, ak: str, sk: str): def __init__(self, ak: str, sk: str):
self.client = get_billing_client(ak, sk) self.client = get_billing_client(ak, sk)
def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal: def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal:
"""查询指定项目的消费总额(带分页)""" """查询指定项目的消费总额(使用分账账单,带分页)"""
total = Decimal("0") total = Decimal("0")
offset = 0 offset = 0
page_size = 300 page_size = 100
while True: while True:
params = { params = {
@ -25,25 +30,52 @@ class BillingService:
"Limit": str(page_size), "Limit": str(page_size),
"Offset": str(offset), "Offset": str(offset),
"GroupTerm": "0", "GroupTerm": "0",
"GroupPeriod": "0", "GroupPeriod": "2",
"NeedRecordNum": "1",
} }
result = self.client.call("ListBillDetail", params) result = self.client.call("ListSplitBillDetail", params)
items = result.get("Result", {}).get("List", []) items = result.get("Result", {}).get("List", [])
record_num = int(result.get("Result", {}).get("Total", 0))
for item in items: for item in items:
if project_name and item.get("Project") != project_name: item_project = item.get("Project", "-")
if project_name and item_project != project_name:
continue continue
amount = item.get("PayableAmount", "0") amount = item.get("PayableAmount", "0")
total += Decimal(str(amount)) total += Decimal(str(amount))
offset += page_size if len(items) < page_size:
if offset >= record_num or not items:
break break
offset += page_size
return total return total
def get_spending_all_projects(self, bill_period: str) -> dict:
"""查询所有项目的消费汇总(返回 {project_name: Decimal}"""
by_project = {}
offset = 0
page_size = 100
while True:
params = {
"BillPeriod": bill_period,
"Limit": str(page_size),
"Offset": str(offset),
"GroupTerm": "0",
"GroupPeriod": "2",
}
result = self.client.call("ListSplitBillDetail", params)
items = result.get("Result", {}).get("List", [])
for item in items:
project = item.get("Project", "-")
amount = Decimal(str(item.get("PayableAmount", "0")))
by_project[project] = by_project.get(project, Decimal("0")) + amount
if len(items) < page_size:
break
offset += page_size
return by_project
def get_bill_overview(self, bill_period: str) -> dict: def get_bill_overview(self, bill_period: str) -> dict:
"""获取账单总览(按产品维度)""" """获取账单总览(按产品维度)"""
result = self.client.call("ListBillOverviewByProd", { result = self.client.call("ListBillOverviewByProd", {

View File

@ -1,42 +1,171 @@
"""飞书机器人通知""" """飞书自建应用通知(复用 AirDrama 的飞书应用,发私信卡片)"""
import json
import logging import logging
import threading import threading
import requests import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Token 缓存
_token_cache = {'token': '', 'expires': 0}
def _get_tenant_access_token(app_id: str, app_secret: str) -> str:
"""获取飞书 tenant_access_token带简单缓存"""
import time
if _token_cache['token'] and _token_cache['expires'] > time.time():
return _token_cache['token']
resp = requests.post(
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
json={'app_id': app_id, 'app_secret': app_secret},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
raise RuntimeError(f'Feishu token error: {data}')
token = data['tenant_access_token']
expire = data.get('expire', 7200)
_token_cache['token'] = token
_token_cache['expires'] = time.time() + expire - 60 # 提前60秒过期
return token
def _get_open_id_by_mobile(token: str, mobile: str) -> str:
"""通过手机号查询飞书 open_id"""
resp = requests.post(
'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id',
headers={'Authorization': f'Bearer {token}'},
json={'mobiles': [mobile]},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
raise RuntimeError(f'Feishu user lookup error: {data}')
user_list = data.get('data', {}).get('user_list', [])
if user_list and user_list[0].get('user_id'):
return user_list[0]['user_id']
return None
def send_feishu_alert(webhook_url: str, title: str, content: str, def send_feishu_alert(webhook_url: str, title: str, content: str,
template: str = "red"): template: str = "red"):
"""发送飞书卡片消息(非阻塞)""" """发送飞书卡片消息到配置的接收人(非阻塞)
if not webhook_url:
logger.warning(f"飞书 Webhook 未配置,跳过通知: {title}") webhook_url 参数保留兼容性但不再使用改为从 GlobalConfig 读取飞书应用配置
"""
def _send():
try:
from apps.monitor.models import GlobalConfig
config = GlobalConfig.get_solo()
app_id = config.feishu_app_id
app_secret = config.feishu_app_secret
mobiles_str = config.feishu_alert_mobiles
if not app_id or not app_secret:
logger.warning(f"飞书应用未配置,跳过通知: {title}")
return return
def _send(): if not mobiles_str:
payload = { logger.warning(f"飞书告警手机号未配置,跳过通知: {title}")
"msg_type": "interactive", return
"card": {
"config": {"wide_screen_mode": True}, mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()]
"header": { if not mobiles:
"title": {"tag": "plain_text", "content": title}, return
"template": template,
token = _get_tenant_access_token(app_id, app_secret)
card = {
'config': {'wide_screen_mode': True},
'header': {
'title': {'tag': 'plain_text', 'content': title},
'template': template,
}, },
"elements": [ 'elements': [
{ {
"tag": "div", 'tag': 'div',
"text": {"tag": "lark_md", "content": content}, 'text': {'tag': 'lark_md', 'content': content},
} }
], ],
},
} }
for mobile in mobiles:
try: try:
resp = requests.post(webhook_url, json=payload, timeout=10) open_id = _get_open_id_by_mobile(token, mobile)
resp.raise_for_status() if not open_id:
logger.info(f"飞书通知已发送: {title}") logger.warning(f'未找到手机号 {mobile} 对应的飞书用户')
continue
resp = requests.post(
'https://open.feishu.cn/open-apis/im/v1/messages',
headers={'Authorization': f'Bearer {token}'},
params={'receive_id_type': 'open_id'},
json={
'receive_id': open_id,
'msg_type': 'interactive',
'content': json.dumps(card, ensure_ascii=False),
},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
logger.error(f'飞书发送失败 {mobile}: {data}')
else:
logger.info(f'飞书通知已发送 {mobile}: {title}')
except Exception as e:
logger.error(f'飞书通知错误 {mobile}: {e}')
except Exception as e: except Exception as e:
logger.error(f"飞书通知发送失败: {e}") logger.error(f"飞书通知发送失败: {e}")
thread = threading.Thread(target=_send, daemon=True) thread = threading.Thread(target=_send, daemon=True)
thread.start() thread.start()
def send_feishu_test(app_id: str, app_secret: str, mobile: str):
"""发送测试消息。Returns (success, message)。"""
try:
token = _get_tenant_access_token(app_id, app_secret)
open_id = _get_open_id_by_mobile(token, mobile)
if not open_id:
return False, f'未找到手机号 {mobile} 对应的飞书用户'
card = {
'config': {'wide_screen_mode': True},
'header': {
'title': {'tag': 'plain_text', 'content': 'AirGate 告警测试'},
'template': 'blue',
},
'elements': [
{
'tag': 'div',
'text': {
'tag': 'lark_md',
'content': '这是一条测试消息,说明飞书告警通道配置正常。',
},
},
],
}
resp = requests.post(
'https://open.feishu.cn/open-apis/im/v1/messages',
headers={'Authorization': f'Bearer {token}'},
params={'receive_id_type': 'open_id'},
json={
'receive_id': open_id,
'msg_type': 'interactive',
'content': json.dumps(card, ensure_ascii=False),
},
timeout=5,
)
data = resp.json()
if data.get('code') != 0:
return False, f'发送失败: {data.get("msg", "")}'
return True, '测试消息已发送'
except Exception as e:
return False, str(e)

View File

@ -76,33 +76,147 @@ class IAMService:
"PolicyType": policy_type, "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: def list_attached_user_policies(self, username: str) -> dict:
return self.client.call("ListAttachedUserPolicies", {"UserName": username}) return self.client.call("ListAttachedUserPolicies", {"UserName": username})
def attach_policy_in_project(self, username: str, policy_name: str, def attach_policy_in_project(self, username: str, policy_name: str,
project_name: str, policy_type: str = "System") -> dict: project_name: str, policy_type: str = "System") -> dict:
"""在项目范围内授权""" """授权策略(全局),项目隔离靠 Deny 策略实现。
注意火山 Open API 不支持项目级授权Scope=Project 无效
所以统一走全局授权 + AirGate_Deny_{username} 策略隔离"""
return self.client.call("AttachUserPolicy", { return self.client.call("AttachUserPolicy", {
"UserName": username, "UserName": username,
"PolicyName": policy_name, "PolicyName": policy_name,
"PolicyType": policy_type, "PolicyType": policy_type,
"ProjectName": project_name,
}) })
def detach_policy_in_project(self, username: str, policy_name: str, def detach_policy_in_project(self, username: str, policy_name: str,
project_name: str, policy_type: str = "System") -> dict: project_name: str, policy_type: str = "System") -> dict:
"""在项目范围内回收权限""" """回收策略(全局)"""
return self.client.call("DetachUserPolicy", { return self.client.call("DetachUserPolicy", {
"UserName": username, "UserName": username,
"PolicyName": policy_name, "PolicyName": policy_name,
"PolicyType": policy_type, "PolicyType": policy_type,
"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
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
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": ["*"],
}]
})
# 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:
pass # Policy doesn't exist yet
self.client.call("CreatePolicy", {
"PolicyName": policy_name,
"PolicyDocument": policy_doc,
"Description": f"AirGate 自动生成:限制 {username} 只能访问授权项目",
})
self.attach_user_policy(username, policy_name, "Custom")
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 _has_login_profile(self, username: str) -> bool:
"""检查用户是否有真实的 LoginProfile火山可能返回空壳"""
try:
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):
return False
raise
def disable_user(self, username: str): def disable_user(self, username: str):
"""完全停用用户:停控制台 + 停所有 AccessKey""" """完全停用用户:停控制台 + 停所有 AccessKey"""
errors = [] errors = []
if self._has_login_profile(username):
try: try:
self.update_login_allowed(username, False) self.update_login_allowed(username, False)
except VolcengineAPIError as e: except VolcengineAPIError as e:
@ -119,10 +233,11 @@ class IAMService:
if errors: if errors:
raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors)) raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors))
def enable_user(self, username: str): def enable_user(self, username: str, restore_login: bool = True):
"""恢复用户:恢复控制台 + 恢复所有 AccessKey""" """恢复用户:恢复控制台(可选) + 恢复所有 AccessKey"""
errors = [] errors = []
if restore_login and self._has_login_profile(username):
try: try:
self.update_login_allowed(username, True) self.update_login_allowed(username, True)
except VolcengineAPIError as e: except VolcengineAPIError as e:

View File

@ -31,6 +31,13 @@ def check_spending():
billing = BillingService(ak, sk) billing = BillingService(ak, sk)
iam_svc = IAMService(ak, sk) iam_svc = IAMService(ak, sk)
# 一次性查询所有项目的消费(避免 N+1 API 调用)
try:
all_project_spending = billing.get_spending_all_projects(bill_period)
except Exception as e:
logger.error(f"批量查询消费失败: {e}")
all_project_spending = {}
users = IAMUser.objects.filter( users = IAMUser.objects.filter(
volc_account=volc_account, volc_account=volc_account,
monitor_enabled=True, monitor_enabled=True,
@ -38,7 +45,7 @@ def check_spending():
for user in users: for user in users:
try: try:
# --- 遍历所有开启监测的项目,分别查询消费并累加 --- # --- 遍历所有开启监测的项目,从批量结果中获取消费 ---
enabled_projects = IAMUserProject.objects.filter( enabled_projects = IAMUserProject.objects.filter(
iam_user=user, monitor_enabled=True iam_user=user, monitor_enabled=True
) )
@ -50,13 +57,9 @@ def check_spending():
total_spending = Decimal('0') total_spending = Decimal('0')
for project in enabled_projects: for project in enabled_projects:
try: proj_spending = all_project_spending.get(
proj_spending = billing.get_spending_by_project( project.project_name, project.current_spending
bill_period, project.project_name
) )
except Exception as e:
logger.error(f"查询项目 {project.project_name} 消费失败: {e}")
proj_spending = project.current_spending # 保留上次值
# 更新项目级消费 # 更新项目级消费
project.current_spending = proj_spending project.current_spending = proj_spending

View File

@ -46,25 +46,25 @@ class VolcengineClient:
def _hash_sha256(self, content: str) -> str: def _hash_sha256(self, content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest() return hashlib.sha256(content.encode("utf-8")).hexdigest()
def call(self, action: str, params: dict = None, body: str = "") -> dict: def _sign_and_call(self, action: str, method: str, content_type: str,
params = params or {} query_params: dict, body_bytes: bytes) -> dict:
"""统一签名并调用"""
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
x_date = now.strftime("%Y%m%dT%H%M%SZ") x_date = now.strftime("%Y%m%dT%H%M%SZ")
short_date = x_date[:8] short_date = x_date[:8]
x_content_sha256 = self._hash_sha256(body) x_content_sha256 = hashlib.sha256(body_bytes).hexdigest()
all_params = {"Action": action, "Version": self.version, **params} query_string = self._norm_query(query_params)
signed_headers_str = "content-type;host;x-content-sha256;x-date" signed_headers_str = "content-type;host;x-content-sha256;x-date"
canonical_headers = ( canonical_headers = (
f"content-type:application/x-www-form-urlencoded\n" f"content-type:{content_type}\n"
f"host:{self.host}\n" f"host:{self.host}\n"
f"x-content-sha256:{x_content_sha256}\n" f"x-content-sha256:{x_content_sha256}\n"
f"x-date:{x_date}" f"x-date:{x_date}"
) )
query_string = self._norm_query(all_params)
canonical_request = "\n".join([ canonical_request = "\n".join([
"GET", "/", query_string, method, "/", query_string,
canonical_headers, "", signed_headers_str, x_content_sha256 canonical_headers, "", signed_headers_str, x_content_sha256
]) ])
@ -84,7 +84,7 @@ class VolcengineClient:
"Host": self.host, "Host": self.host,
"X-Date": x_date, "X-Date": x_date,
"X-Content-Sha256": x_content_sha256, "X-Content-Sha256": x_content_sha256,
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": content_type,
"Authorization": ( "Authorization": (
f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, " f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, "
f"SignedHeaders={signed_headers_str}, Signature={signature}" f"SignedHeaders={signed_headers_str}, Signature={signature}"
@ -93,7 +93,10 @@ class VolcengineClient:
url = f"https://{self.host}/?{query_string}" url = f"https://{self.host}/?{query_string}"
try: try:
if method == "GET":
r = requests.get(url, headers=headers, timeout=30) r = requests.get(url, headers=headers, timeout=30)
else:
r = requests.post(url, headers=headers, data=body_bytes, timeout=30)
resp = r.json() resp = r.json()
except Exception as e: except Exception as e:
raise VolcengineAPIError(action, "NetworkError", str(e)) raise VolcengineAPIError(action, "NetworkError", str(e))
@ -105,6 +108,21 @@ class VolcengineClient:
) )
return resp 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: def get_iam_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com") return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com")
@ -118,3 +136,9 @@ def get_billing_client(ak: str, sk: str) -> VolcengineClient:
def get_resource_client(ak: str, sk: str) -> VolcengineClient: def get_resource_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com", return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
version="2021-08-01") 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")

View File

@ -1,7 +1,7 @@
version: '3.8' version: '3.8'
services: services:
airgate-backend: backend:
build: ./backend build: ./backend
ports: ports:
- "8101:8100" - "8101:8100"
@ -15,12 +15,12 @@ services:
- backend-data:/app/data - backend-data:/app/data
restart: unless-stopped restart: unless-stopped
airgate-web: frontend:
build: ./frontend build: ./frontend
ports: ports:
- "5174:80" - "5174:80"
depends_on: depends_on:
- airgate-backend - backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@ -2,7 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm config set registry https://registry.npmmirror.com && npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build

View File

@ -5,7 +5,7 @@ server {
index index.html; index index.html;
location /api/ { location /api/ {
proxy_pass http://airgate-backend:8100; proxy_pass http://backend:8100;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -4,7 +4,10 @@
<div class="logo">AirGate</div> <div class="logo">AirGate</div>
<el-menu :default-active="route.path" router background-color="#1d1e2c" <el-menu :default-active="route.path" router background-color="#1d1e2c"
text-color="#a0a3bd" active-text-color="#fff"> text-color="#a0a3bd" active-text-color="#fff">
<el-menu-item index="/">
<!-- Admin menus -->
<template v-if="auth.isAdmin">
<el-menu-item index="/dashboard">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>仪表盘</span> <span>仪表盘</span>
</el-menu-item> </el-menu-item>
@ -12,6 +15,10 @@
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>子账号管理</span> <span>子账号管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/ark-keys">
<el-icon><Key /></el-icon>
<span>API Key 管理</span>
</el-menu-item>
<el-menu-item index="/billing"> <el-menu-item index="/billing">
<el-icon><Wallet /></el-icon> <el-icon><Wallet /></el-icon>
<span>消费监控</span> <span>消费监控</span>
@ -25,16 +32,32 @@
<span>系统设置</span> <span>系统设置</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/admin"> <el-menu-item index="/admin">
<el-icon><Key /></el-icon> <el-icon><Tools /></el-icon>
<span>系统管理</span> <span>系统管理</span>
</el-menu-item> </el-menu-item>
</template>
<!-- IAM User (sub-account) menus -->
<template v-else>
<el-menu-item index="/my-keys">
<el-icon><Key /></el-icon>
<span>我的 API Key</span>
</el-menu-item>
<el-menu-item index="/my-password">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-menu-item>
</template>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
<el-header style="display: flex; align-items: center; justify-content: flex-end; <el-header style="display: flex; align-items: center; justify-content: flex-end;
background: #fff; border-bottom: 1px solid #eee; height: 56px;"> background: #fff; border-bottom: 1px solid #eee; height: 56px;">
<span style="margin-right: 16px; color: #666;">{{ auth.user?.username }}</span> <el-tag v-if="auth.isIamUser" type="info" size="small" style="margin-right: 12px;">子账号</el-tag>
<span style="margin-right: 16px; color: #666;">
{{ auth.user?.display_name || auth.user?.username }}
</span>
<el-button text @click="handleLogout">退出登录</el-button> <el-button text @click="handleLogout">退出登录</el-button>
</el-header> </el-header>
<el-main style="background: #f5f7fa; padding: 24px;"> <el-main style="background: #f5f7fa; padding: 24px;">

View File

@ -12,12 +12,24 @@ const routes = [
path: '/', path: '/',
component: () => import('../layouts/MainLayout.vue'), component: () => import('../layouts/MainLayout.vue'),
children: [ 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: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') },
{ path: 'iam-users/:id/policies', name: 'UserPolicies', component: () => import('../views/iam/UserPoliciesView.vue'), props: true },
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') }, { path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.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: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.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') },
{ path: 'my-password', name: 'MyPassword', component: () => import('../views/portal/MyPasswordView.vue') },
], ],
}, },
] ]

View File

@ -7,13 +7,15 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null')) const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null'))
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role !== 'iam_user')
const isIamUser = computed(() => user.value?.role === 'iam_user')
function setAuth(data) { function setAuth(data) {
token.value = data.access token.value = data.access
refreshToken.value = data.refresh refreshToken.value = data.refresh || ''
user.value = data.user user.value = data.user
localStorage.setItem('airgate_token', data.access) 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)) localStorage.setItem('airgate_user', JSON.stringify(data.user))
} }
@ -26,5 +28,5 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('airgate_user') localStorage.removeItem('airgate_user')
} }
return { token, refreshToken, user, isLoggedIn, setAuth, logout } return { token, refreshToken, user, isLoggedIn, isAdmin, isIamUser, setAuth, logout }
}) })

View File

@ -0,0 +1,20 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
onMounted(() => {
if (auth.isIamUser) {
router.replace('/my-keys')
} else {
router.replace('/dashboard')
}
})
</script>
<template>
<div></div>
</template>

View File

@ -5,9 +5,14 @@
<h1>AirGate</h1> <h1>AirGate</h1>
<p>火山引擎 IAM 子账号管控平台</p> <p>火山引擎 IAM 子账号管控平台</p>
</div> </div>
<el-segmented v-model="loginMode" :options="loginModes" block
style="margin-bottom: 24px;" />
<el-form :model="form" @submit.prevent="handleLogin" label-position="top"> <el-form :model="form" @submit.prevent="handleLogin" label-position="top">
<el-form-item label="用户名"> <el-form-item label="用户名">
<el-input v-model="form.username" placeholder="admin" size="large" /> <el-input v-model="form.username" :placeholder="loginMode === 'admin' ? 'admin' : '子账号用户名'"
size="large" />
</el-form-item> </el-form-item>
<el-form-item label="密码"> <el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="密码" size="large" <el-input v-model="form.password" type="password" placeholder="密码" size="large"
@ -32,6 +37,12 @@ import api from '../api'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const loginMode = ref('admin')
const loginModes = [
{ label: '管理员登录', value: 'admin' },
{ label: '子账号登录', value: 'iam' },
]
const form = ref({ username: '', password: '' }) const form = ref({ username: '', password: '' })
const loading = ref(false) const loading = ref(false)
@ -42,11 +53,14 @@ async function handleLogin() {
} }
loading.value = true loading.value = true
try { try {
const { data } = await api.post('/api/v1/auth/login/', form.value) const url = loginMode.value === 'admin'
? '/api/v1/auth/login/'
: '/api/v1/auth/iam/login/'
const { data } = await api.post(url, form.value)
auth.setAuth(data) auth.setAuth(data)
router.push('/') router.push('/')
} catch (err) { } catch (err) {
ElMessage.error(err.response?.data?.message || '登录失败') ElMessage.error(err.response?.data?.message || '登录失败,请重试')
} finally { } finally {
loading.value = false loading.value = false
} }
@ -65,7 +79,7 @@ async function handleLogin() {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 48px 40px; padding: 48px 40px;
width: 400px; width: 420px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
} }
.login-header { .login-header {

View File

@ -0,0 +1,239 @@
<template>
<div style="max-width: 1400px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 style="margin: 0;">API Key 管理</h2>
<el-button type="primary" @click="openCreate">录入 API Key</el-button>
</div>
<!-- Filters -->
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
<el-select v-model="filterUser" placeholder="按子账号筛选" clearable filterable
style="width: 200px;" @change="loadKeys">
<el-option v-for="u in users" :key="u.id" :label="u.display_name || u.username"
:value="u.id" />
</el-select>
<el-select v-model="filterProject" placeholder="按项目筛选" clearable filterable
style="width: 200px;" @change="loadKeys">
<el-option v-for="p in allProjects" :key="p" :label="p" :value="p" />
</el-select>
<el-button @click="loadKeys" text><el-icon><Refresh /></el-icon></el-button>
</div>
<!-- Keys table -->
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
empty-text="暂无 API Key">
<el-table-column label="子账号" min-width="120">
<template #default="{ row }">
{{ row.iam_display_name || row.iam_username }}
</template>
</el-table-column>
<el-table-column prop="project_name" label="所属项目" min-width="150" />
<el-table-column prop="key_name" label="名称/用途" min-width="160" />
<el-table-column label="API Key" min-width="200">
<template #default="{ row }">
<code style="color: #999;">{{ row.api_key_hint }}</code>
<el-button size="small" text type="primary" @click="handleReveal(row)"
style="margin-left: 4px;">查看</el-button>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="录入时间" min-width="160">
<template #default="{ row }">
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="180">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" size="small" text type="warning"
@click="handleToggle(row, 'disabled')">停用</el-button>
<el-button v-else size="small" text type="success"
@click="handleToggle(row, 'active')">启用</el-button>
<el-button size="small" text type="danger"
@click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Create (manual entry) dialog -->
<el-dialog v-model="showCreate" title="录入 API Key" width="90%" style="max-width: 600px;">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px;">
请先在火山控制台创建 API Key然后将完整 Key 粘贴到下方录入
</el-alert>
<el-form label-width="100px">
<el-form-item label="子账号" required>
<el-select v-model="createForm.iam_user_id" placeholder="选择子账号" filterable style="width: 100%;">
<el-option v-for="u in users" :key="u.id"
:label="`${u.username} (${u.display_name || '-'})`" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="所属项目" required>
<el-select v-model="createForm.project_name" placeholder="选择项目" filterable style="width: 100%;">
<el-option v-for="p in volcProjects" :key="p.name"
:label="p.display_name || p.name" :value="p.name" />
</el-select>
</el-form-item>
<el-form-item label="名称/用途" required>
<el-input v-model="createForm.key_name" placeholder="如zyc-seedance-production" />
</el-form-item>
<el-form-item label="API Key" required>
<el-input v-model="createForm.api_key" type="textarea" :rows="2"
placeholder="粘贴完整的 API Key" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleCreate" :loading="creating">录入</el-button>
</template>
</el-dialog>
<!-- Reveal key dialog -->
<el-dialog v-model="showReveal" title="查看 API Key" width="90%" style="max-width: 600px;">
<div style="margin-bottom: 8px; color: #606266;">
<strong>{{ revealData.key_name }}</strong> · {{ revealData.project_name }}
</div>
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; word-break: break-all;">
<code style="font-size: 14px; color: #409eff;">{{ revealData.api_key }}</code>
</div>
<template #footer>
<el-button type="primary" @click="copyRevealKey">复制</el-button>
<el-button @click="showReveal = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../../api'
const keys = ref([])
const loading = ref(false)
const users = ref([])
const volcProjects = ref([])
const filterUser = ref('')
const filterProject = ref('')
const allProjects = computed(() => {
const set = new Set(keys.value.map(k => k.project_name))
return [...set].sort()
})
const showCreate = ref(false)
const createForm = ref({ iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' })
const creating = ref(false)
const showReveal = ref(false)
const revealData = ref({ api_key: '', key_name: '', project_name: '' })
async function loadKeys() {
loading.value = true
try {
const params = {}
if (filterUser.value) params.iam_user_id = filterUser.value
if (filterProject.value) params.project_name = filterProject.value
const { data } = await api.get('/api/v1/ark-keys/', { params })
keys.value = data
} catch (e) {
keys.value = []
} finally {
loading.value = false
}
}
async function loadUsers() {
try {
const { data } = await api.get('/api/v1/iam-users/')
users.value = data
} catch { users.value = [] }
}
async function loadVolcProjects() {
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch { volcProjects.value = [] }
}
function openCreate() {
createForm.value = { iam_user_id: '', project_name: '', key_name: '', api_key: '', remark: '' }
showCreate.value = true
loadVolcProjects()
}
async function handleCreate() {
const f = createForm.value
if (!f.iam_user_id || !f.project_name || !f.key_name || !f.api_key) {
ElMessage.warning('请填写完整')
return
}
creating.value = true
try {
const { data } = await api.post('/api/v1/ark-keys/create/', f)
ElMessage.success(data.message)
showCreate.value = false
await loadKeys()
} catch (e) {
ElMessage.error(e.response?.data?.message || '录入失败')
} finally {
creating.value = false
}
}
async function handleReveal(row) {
try {
const { data } = await api.get(`/api/v1/ark-keys/${row.id}/reveal/`)
revealData.value = data
showReveal.value = true
} catch (e) {
ElMessage.error('获取 Key 失败')
}
}
async function copyRevealKey() {
try {
await navigator.clipboard.writeText(revealData.value.api_key)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
async function handleToggle(row, newStatus) {
const action = newStatus === 'active' ? '启用' : '停用'
try {
await api.put(`/api/v1/ark-keys/${row.id}/`, { status: newStatus })
ElMessage.success(`${action}`)
await loadKeys()
} catch (e) {
ElMessage.error(`${action}失败`)
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除 "${row.key_name}" 吗?`, '确认删除', { type: 'error' })
try {
await api.delete(`/api/v1/ark-keys/${row.id}/delete/`)
ElMessage.success('已删除')
await loadKeys()
} catch (e) {
ElMessage.error('删除失败')
}
}
onMounted(() => {
loadKeys()
loadUsers()
})
</script>

View File

@ -51,11 +51,17 @@
<span v-else style="color:#999;font-size:12px;">未划拨</span> <span v-else style="color:#999;font-size:12px;">未划拨</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="项目" min-width="80" align="center"> <el-table-column label="关联项目" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="openProjectsDialog(row)"> <div style="display:flex; flex-wrap:wrap; gap:4px; align-items:center;">
{{ row.monitored_project_count || 0 }} / {{ (row.projects || []).length }} <el-tag v-for="p in (row.projects || [])" :key="p.project_name"
</el-button> :type="p.monitor_enabled ? 'success' : 'info'" size="small">
{{ p.project_name }}
</el-tag>
<el-button link type="primary" size="small" @click="$router.push(`/iam-users/${row.id}/policies`)"
style="font-size:12px;">管理</el-button>
</div>
<span v-if="!(row.projects || []).length" style="color:#999;font-size:12px;">未关联</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警" min-width="110" align="center"> <el-table-column label="告警" min-width="110" align="center">
@ -76,10 +82,14 @@
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="openProjectsDialog(row)">项目管理</el-dropdown-item>
<el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item> <el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item>
<el-dropdown-item @click="openPolicies(row)">权限策略</el-dropdown-item> <el-dropdown-item @click="openEditProfile(row)">编辑信息</el-dropdown-item>
<el-dropdown-item @click="toggleVolcLogin(row)">
{{ row.volc_login_allowed ? '🔓 关闭火山登录' : '🔒 开启火山登录' }}
</el-dropdown-item>
<el-dropdown-item @click="$router.push(`/iam-users/${row.id}/policies`)">权限管理</el-dropdown-item>
<el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item> <el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item>
<el-dropdown-item @click="openSetLogin(row)">登录密码</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'active'" divided <el-dropdown-item v-if="row.status === 'active'" divided
@click="handleDisable(row)" style="color:#f56c6c;">停用账号</el-dropdown-item> @click="handleDisable(row)" style="color:#f56c6c;">停用账号</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'disabled'" divided <el-dropdown-item v-if="row.status === 'disabled'" divided
@ -151,6 +161,10 @@
<el-switch v-model="configForm.auto_disable_enabled" /> <el-switch v-model="configForm.auto_disable_enabled" />
<span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span> <span class="switch-hint">{{ configForm.auto_disable_enabled ? '消费达100%额度时自动停用' : '仅通知不停用' }}</span>
</el-form-item> </el-form-item>
<el-form-item label="免除 Deny 策略">
<el-switch v-model="configForm.deny_policy_exempt" />
<span class="switch-hint">{{ configForm.deny_policy_exempt ? '不限制项目访问(管理员账号)' : '按关联项目限制访问' }}</span>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="configVisible = false">取消</el-button> <el-button @click="configVisible = false">取消</el-button>
@ -158,58 +172,23 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Projects Dialog --> <!-- Set Login Password Dialog -->
<el-dialog v-model="projectsDialogVisible" :title="`${projectsUser?.username} 关联项目`" width="90%" style="max-width: 900px;"> <el-dialog v-model="loginPwdVisible" :title="`设置 ${loginPwdUser?.username} 的 AirGate 登录密码`"
<div style="margin-bottom:12px; display:flex; gap:8px; align-items:center;"> width="90%" style="max-width: 450px;">
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="flex:1;" <el-form label-width="100px">
:loading="volcProjectsLoading"> <el-form-item label="登录状态">
<el-option v-for="p in volcProjects" :key="p.name" :label="p.display_name || p.name" :value="p.name" /> <el-switch v-model="loginPwdEnabled"
</el-select> active-text="允许登录" inactive-text="禁止登录" />
<el-button @click="loadVolcProjects" :loading="volcProjectsLoading" text> </el-form-item>
<el-icon><Refresh /></el-icon> <el-form-item label="新密码">
</el-button> <el-input v-model="loginPwdValue" type="password" show-password
</div> placeholder="至少6位留空则不修改密码" />
<div v-if="projectToAdd" style="margin-bottom:12px;"> </el-form-item>
<div style="margin-bottom:4px; font-size:13px; color:#606266;">授权策略可多选不选则仅加入监测不授权</div> </el-form>
<el-checkbox-group v-model="projectPoliciesToAttach"> <template #footer>
<el-checkbox label="ArkFullAccess">方舟/Seedance 完整权限</el-checkbox> <el-button @click="loginPwdVisible = false">取消</el-button>
<el-checkbox label="ArkReadOnlyAccess">方舟只读</el-checkbox> <el-button type="primary" @click="handleSetLogin" :loading="loginPwdSaving">保存</el-button>
<el-checkbox label="TOSFullAccess">对象存储完整权限</el-checkbox>
<el-checkbox label="TOSReadOnlyAccess">对象存储只读</el-checkbox>
<el-checkbox label="AccessKeySelfManageAccess">自管理密钥</el-checkbox>
</el-checkbox-group>
<el-button type="primary" @click="handleAddProject" style="margin-top:8px;">确认添加</el-button>
</div>
<div style="margin-bottom:12px;">
<el-button size="small" @click="handleToggleAll(true)">全部开启监测</el-button>
<el-button size="small" @click="handleToggleAll(false)">全部关闭监测</el-button>
</div>
<el-table :data="userProjects" stripe v-loading="projectsDialogLoading" empty-text="暂无关联项目">
<el-table-column prop="project_name" label="项目名" min-width="160" />
<el-table-column prop="display_name" label="显示名" min-width="120" />
<el-table-column label="消费" min-width="100" align="right">
<template #default="{ row }">
<span style="color:#e6a23c;">¥{{ Number(row.current_spending).toLocaleString() }}</span>
</template> </template>
</el-table-column>
<el-table-column label="已授权策略" min-width="180">
<template #default="{ row }">
<el-tag v-for="p in (row.attached_policies || [])" :key="p" size="small"
style="margin:1px 2px;">{{ p }}</el-tag>
<span v-if="!(row.attached_policies || []).length" style="color:#999;font-size:12px;"></span>
</template>
</el-table-column>
<el-table-column label="监测" min-width="70" align="center">
<template #default="{ row }">
<el-switch :model-value="row.monitor_enabled" @change="val => handleToggleProject(row, val)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleRemoveProject(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog> </el-dialog>
<!-- Quota History Dialog --> <!-- Quota History Dialog -->
@ -233,31 +212,6 @@
</el-table> </el-table>
</el-dialog> </el-dialog>
<!-- Policies Dialog -->
<el-dialog v-model="policiesVisible" :title="`${policiesUser?.username} 权限策略`" width="90%" style="max-width: 850px;">
<div style="margin-bottom:12px; display:flex; gap:8px;">
<el-select v-model="policyToAttach" placeholder="选择要附加的策略" filterable style="flex:1;">
<el-option-group label="常用策略">
<el-option value="ArkFullAccess" label="ArkFullAccess方舟/Seedance 完整权限)" />
<el-option value="ArkReadOnlyAccess" label="ArkReadOnlyAccess方舟只读" />
<el-option value="TOSFullAccess" label="TOSFullAccess对象存储完整权限" />
<el-option value="TOSReadOnlyAccess" label="TOSReadOnlyAccess对象存储只读" />
<el-option value="AccessKeySelfManageAccess" label="AccessKeySelfManageAccess自管理密钥" />
</el-option-group>
</el-select>
<el-button type="primary" @click="handleAttachPolicy" :disabled="!policyToAttach">附加</el-button>
</div>
<el-table :data="policies" stripe v-loading="policiesLoading" empty-text="暂无策略">
<el-table-column prop="PolicyName" label="策略名" />
<el-table-column prop="PolicyType" label="类型" width="80" />
<el-table-column prop="Description" label="说明" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="handleDetachPolicy(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- Create User Dialog --> <!-- Create User Dialog -->
<el-dialog v-model="showCreate" title="创建子账号" width="90%" style="max-width: 580px;"> <el-dialog v-model="showCreate" title="创建子账号" width="90%" style="max-width: 580px;">
@ -280,7 +234,8 @@
<el-input v-model="createForm.password" type="password" show-password <el-input v-model="createForm.password" type="password" show-password
placeholder="选填" /> placeholder="选填" />
<div style="font-size:12px;color:#999;margin-top:4px;"> <div style="font-size:12px;color:#999;margin-top:4px;">
火山引擎网页后台的登录密码不填则子账号无法登录火山网页后台仅能通过 API Key 使用服务 火山引擎网页后台的登录密码不填则子账号无法登录火山网页后台仅能通过 API Key 使用服务
密码需包含大小写字母数字和特殊字符至少8位 User@1234
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="关联项目"> <el-form-item label="关联项目">
@ -297,6 +252,32 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- Edit Profile Dialog -->
<el-dialog v-model="editProfileVisible" :title="`编辑 ${editProfileUser?.username} 信息`"
width="90%" style="max-width: 500px;">
<el-form :model="editProfileForm" label-width="80px">
<el-form-item label="用户名">
<el-input :model-value="editProfileUser?.username" disabled />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="editProfileForm.display_name" placeholder="如:视频部门" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editProfileForm.phone" placeholder="如 13800138000" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editProfileForm.email" placeholder="选填" />
</el-form-item>
</el-form>
<div style="font-size:12px; color:#999; margin-top:8px;">
修改会同步到火山引擎 IAM
</div>
<template #footer>
<el-button @click="editProfileVisible = false">取消</el-button>
<el-button type="primary" @click="handleEditProfile" :loading="editProfileSaving">保存</el-button>
</template>
</el-dialog>
<!-- Secret Key Display Dialog --> <!-- Secret Key Display Dialog -->
<el-dialog v-model="showSecretKey" title="API 密钥已生成" width="90%" style="max-width: 580px;" :close-on-click-modal="false"> <el-dialog v-model="showSecretKey" title="API 密钥已生成" width="90%" style="max-width: 580px;" :close-on-click-modal="false">
<el-alert type="error" :closable="false" style="margin-bottom:16px;" <el-alert type="error" :closable="false" style="margin-bottom:16px;"
@ -401,23 +382,73 @@ async function handleEnable(row) {
} }
} }
// Policies // Toggle Volcengine console login
const policiesVisible = ref(false) async function toggleVolcLogin(row) {
const policiesUser = ref(null) const action = row.volc_login_allowed ? '关闭' : '开启'
const policies = ref([]) await ElMessageBox.confirm(
const policiesLoading = ref(false) `确定${action} "${row.username}" 的火山引擎控制台登录?`,
const policyToAttach = ref('') `${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 || '操作失败')
}
}
// Projects dialog // Edit Profile
const projectsDialogVisible = ref(false) const editProfileVisible = ref(false)
const projectsUser = ref(null) const editProfileUser = ref(null)
const userProjects = ref([]) const editProfileForm = ref({ display_name: '', phone: '', email: '' })
const projectsDialogLoading = ref(false) const editProfileSaving = ref(false)
const projectToAdd = ref('')
const projectPoliciesToAttach = ref([]) function openEditProfile(row) {
editProfileUser.value = row
editProfileForm.value = {
display_name: row.display_name || '',
phone: row.phone || '',
email: row.email || '',
}
editProfileVisible.value = true
}
async function handleEditProfile() {
editProfileSaving.value = true
try {
const { data } = await api.post(
`/api/v1/iam-users/${editProfileUser.value.id}/edit-profile/`,
editProfileForm.value
)
ElMessage.success(data.message)
editProfileVisible.value = false
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '更新失败')
} finally {
editProfileSaving.value = false
}
}
// (Policies dialog removed - now in UserPoliciesView)
// Volcengine projects (for create dialog)
const volcProjects = ref([]) const volcProjects = ref([])
const volcProjectsLoading = ref(false) const volcProjectsLoading = ref(false)
async function loadVolcProjects() {
volcProjectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch (e) {
ElMessage.error('获取火山项目列表失败')
} finally {
volcProjectsLoading.value = false
}
}
// --- Allocate --- // --- Allocate ---
const maxDeduct = computed(() => { const maxDeduct = computed(() => {
if (!allocateUser.value) return 0 if (!allocateUser.value) return 0
@ -467,102 +498,12 @@ function openConfig(row) {
alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])], alert_thresholds: [...(row.alert_thresholds?.length ? row.alert_thresholds : row.effective_alert_thresholds || [50, 80, 90])],
monitor_enabled: row.monitor_enabled, monitor_enabled: row.monitor_enabled,
auto_disable_enabled: row.auto_disable_enabled, auto_disable_enabled: row.auto_disable_enabled,
deny_policy_exempt: row.deny_policy_exempt || false,
} }
newStep.value = null newStep.value = null
configVisible.value = true configVisible.value = true
} }
// --- Projects Dialog ---
async function loadVolcProjects() {
volcProjectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取火山项目列表失败')
} finally {
volcProjectsLoading.value = false
}
}
async function openProjectsDialog(row) {
projectsUser.value = row
projectsDialogVisible.value = true
projectToAdd.value = ''
await loadUserProjects(row.id)
if (volcProjects.value.length === 0) loadVolcProjects()
}
async function loadUserProjects(userId) {
projectsDialogLoading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${userId}/projects/`)
userProjects.value = data
} catch (e) {
ElMessage.error('获取项目列表失败')
userProjects.value = []
} finally {
projectsDialogLoading.value = false
}
}
async function handleAddProject() {
if (!projectToAdd.value) return
try {
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/add/`, {
project_name: projectToAdd.value,
policies: projectPoliciesToAttach.value,
})
const policyMsg = data.attached_policies?.length
? `,已授权 ${data.attached_policies.length} 个策略`
: ''
ElMessage.success(`已添加${policyMsg}`)
projectToAdd.value = ''
projectPoliciesToAttach.value = []
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function handleToggleProject(row, val) {
try {
await api.put(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/`, {
monitor_enabled: val,
})
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('切换失败')
}
}
async function handleRemoveProject(row) {
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
try {
await api.delete(`/api/v1/iam-users/${projectsUser.value.id}/projects/${row.id}/delete/`)
ElMessage.success('已移除')
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('移除失败')
}
}
async function handleToggleAll(enable) {
try {
const { data } = await api.post(`/api/v1/iam-users/${projectsUser.value.id}/projects/toggle-all/`, {
monitor_enabled: enable,
})
ElMessage.success(data.message)
await loadUserProjects(projectsUser.value.id)
await loadUsers()
} catch (e) {
ElMessage.error('操作失败')
}
}
function addStep() { function addStep() {
if (!newStep.value || newStep.value < 1 || newStep.value > 99) { if (!newStep.value || newStep.value < 1 || newStep.value > 99) {
ElMessage.warning('请输入 1-99 之间的百分比') ElMessage.warning('请输入 1-99 之间的百分比')
@ -596,6 +537,42 @@ async function saveConfig() {
} }
// --- Quota History --- // --- 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) { async function openQuotaHistory(row) {
historyUser.value = row historyUser.value = row
historyVisible.value = true historyVisible.value = true
@ -611,52 +588,6 @@ async function openQuotaHistory(row) {
} }
} }
// --- Policies ---
async function openPolicies(row) {
policiesUser.value = row
policiesVisible.value = true
policiesLoading.value = true
policyToAttach.value = ''
try {
const { data } = await api.get(`/api/v1/iam-users/${row.id}/policies/`)
policies.value = data.policies || []
} catch (e) {
ElMessage.error(e.response?.data?.message || '获取权限失败')
policies.value = []
} finally {
policiesLoading.value = false
}
}
async function handleAttachPolicy() {
if (!policyToAttach.value) return
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/attach/`, {
policy_name: policyToAttach.value,
policy_type: 'System',
})
ElMessage.success(`已附加 ${policyToAttach.value}`)
policyToAttach.value = ''
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '附加失败')
}
}
async function handleDetachPolicy(row) {
await ElMessageBox.confirm(`确定移除策略 "${row.PolicyName}" 吗?`, '确认移除', { type: 'warning' })
try {
await api.post(`/api/v1/iam-users/${policiesUser.value.id}/policies/detach/`, {
policy_name: row.PolicyName,
policy_type: row.PolicyType,
})
ElMessage.success(`已移除 ${row.PolicyName}`)
await openPolicies(policiesUser.value)
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
// --- Create User --- // --- Create User ---
async function handleCreate() { async function handleCreate() {
if (!createForm.value.username) { if (!createForm.value.username) {

View File

@ -0,0 +1,282 @@
<template>
<div style="max-width: 1200px; margin: 0 auto;">
<div class="page-header">
<h2>{{ overview.display_name || overview.username || '...' }} 权限管理</h2>
<el-button @click="$router.push('/iam-users')">返回子账号列表</el-button>
</div>
<div v-loading="loading">
<!-- Global Policies -->
<el-card style="margin-bottom: 20px;">
<template #header>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:600; font-size:16px;">全局策略</span>
<div style="display:flex; gap:8px;">
<el-select v-model="globalPolicyToAdd" placeholder="添加全局策略" filterable size="small" style="width:280px;">
<el-option v-for="opt in policyOptions" :key="opt.value"
:value="opt.value" :label="opt.label"
:disabled="overview.global_policies?.some(p => p.name === opt.value)" />
</el-select>
<el-button type="primary" size="small" @click="attachGlobal" :disabled="!globalPolicyToAdd">添加</el-button>
</div>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom:12px;">
全局策略对所有项目生效一般只放 Deny 策略项目隔离业务权限请加到项目级
</el-alert>
<el-table :data="overview.global_policies || []" stripe empty-text="无全局策略" table-layout="auto">
<el-table-column prop="name" label="策略名" min-width="220" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.type === 'Custom' ? 'warning' : 'info'" size="small">
{{ row.type === 'Custom' ? '自定义' : '系统' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="detachGlobal(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Add Project Section -->
<el-card style="margin-bottom: 20px;">
<template #header>
<span style="font-weight:600; font-size:16px;">关联项目</span>
</template>
<div style="display:flex; gap:8px; align-items:flex-start; flex-wrap:wrap;">
<el-select v-model="projectToAdd" placeholder="选择火山项目" filterable style="width:260px;"
:loading="volcProjectsLoading" @focus="loadVolcProjects">
<el-option v-for="p in volcProjects" :key="p.name"
:label="p.display_name || p.name" :value="p.name"
:disabled="(overview.project_policies || []).some(pp => pp.project_name === p.name)" />
</el-select>
<div v-if="projectToAdd" style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
<el-checkbox-group v-model="projectPoliciesToAttach" style="display:flex; flex-wrap:wrap; gap:4px;">
<el-checkbox label="ArkFullAccess" size="small">方舟完整</el-checkbox>
<el-checkbox label="TOSFullAccess" size="small">TOS完整</el-checkbox>
<el-checkbox label="ArkReadOnlyAccess" size="small">方舟只读</el-checkbox>
</el-checkbox-group>
<el-button type="primary" size="small" @click="handleAddProject">确认添加</el-button>
</div>
</div>
</el-card>
<!-- Project-level Policies -->
<el-card v-for="proj in (overview.project_policies || [])" :key="proj.project_name" style="margin-bottom: 16px;">
<template #header>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span>
<el-tag type="success" size="small" style="margin-right:8px;">项目</el-tag>
<span style="font-weight:600; font-size:15px;">{{ proj.project_name }}</span>
<span v-if="proj.display_name" style="color:#999; margin-left:8px;">{{ proj.display_name }}</span>
<span style="color:#e6a23c; margin-left:12px; font-size:13px;">消费: ¥{{ Number(proj.current_spending || 0).toLocaleString() }}</span>
<el-switch :model-value="proj.monitor_enabled" @change="val => toggleMonitor(proj, val)"
active-text="监测" inactive-text="" size="small" style="margin-left:12px;" />
</span>
<div style="display:flex; gap:8px;">
<el-select v-model="projectPolicyToAdd[proj.project_name]" placeholder="添加策略" filterable size="small" style="width:260px;">
<el-option v-for="opt in policyOptions" :key="opt.value"
:value="opt.value" :label="opt.label"
:disabled="proj.policies?.some(p => p.name === opt.value)" />
</el-select>
<el-button type="primary" size="small" @click="attachProject(proj)" :disabled="!projectPolicyToAdd[proj.project_name]">添加</el-button>
<el-button type="danger" size="small" text @click="removeProject(proj)">移除项目</el-button>
</div>
</div>
</template>
<el-table :data="proj.policies || []" stripe empty-text="无项目级策略" table-layout="auto">
<el-table-column prop="name" label="策略名" min-width="220" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.type === 'Custom' ? 'warning' : 'info'" size="small">
{{ row.type === 'Custom' ? '自定义' : '系统' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="detachProject(proj, row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-empty v-if="!(overview.project_policies || []).length && !loading"
description="暂无关联项目,请在上方添加" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../../api'
const route = useRoute()
const userId = route.params.id
const loading = ref(false)
const overview = ref({})
const globalPolicyToAdd = ref('')
const projectPolicyToAdd = reactive({})
// Add project
const projectToAdd = ref('')
const projectPoliciesToAttach = ref([])
const volcProjects = ref([])
const volcProjectsLoading = ref(false)
const policyOptions = [
{ value: 'ArkFullAccess', label: 'ArkFullAccess方舟/Seedance 完整权限)' },
{ value: 'ArkExperienceAccess', label: 'ArkExperienceAccess方舟体验权限' },
{ value: 'ArkReadOnlyAccess', label: 'ArkReadOnlyAccess方舟只读' },
{ value: 'TOSFullAccess', label: 'TOSFullAccess对象存储完整权限' },
{ value: 'TOSReadOnlyAccess', label: 'TOSReadOnlyAccess对象存储只读' },
{ value: 'AccessKeySelfManageAccess', label: 'AccessKeySelfManageAccess自管理密钥' },
]
async function loadOverview() {
loading.value = true
try {
const { data } = await api.get(`/api/v1/iam-users/${userId}/policies/overview/`)
overview.value = data
} catch (e) {
ElMessage.error(e.response?.data?.message || '加载权限信息失败')
} finally {
loading.value = false
}
}
async function loadVolcProjects() {
if (volcProjects.value.length) return
volcProjectsLoading.value = true
try {
const { data } = await api.get('/api/v1/projects/')
volcProjects.value = data
} catch (e) {
ElMessage.error('获取火山项目列表失败')
} finally {
volcProjectsLoading.value = false
}
}
// === Global policies ===
async function attachGlobal() {
if (!globalPolicyToAdd.value) return
try {
await api.post(`/api/v1/iam-users/${userId}/policies/attach/`, {
policy_name: globalPolicyToAdd.value,
policy_type: 'System',
})
ElMessage.success(`已添加全局策略 ${globalPolicyToAdd.value}`)
globalPolicyToAdd.value = ''
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function detachGlobal(row) {
await ElMessageBox.confirm(`确定移除全局策略 "${row.name}" 吗?`, '确认移除', { type: 'warning' })
try {
await api.post(`/api/v1/iam-users/${userId}/policies/detach/`, {
policy_name: row.name,
policy_type: row.type,
})
ElMessage.success(`已移除 ${row.name}`)
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
// === Project management ===
async function handleAddProject() {
if (!projectToAdd.value) return
try {
await api.post(`/api/v1/iam-users/${userId}/projects/add/`, {
project_name: projectToAdd.value,
policies: projectPoliciesToAttach.value,
})
ElMessage.success(`已关联项目 ${projectToAdd.value}`)
projectToAdd.value = ''
projectPoliciesToAttach.value = []
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function removeProject(proj) {
await ElMessageBox.confirm(`确定移除项目 "${proj.project_name}" 吗?权限将被回收。`, '确认移除', { type: 'warning' })
try {
await api.delete(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/delete/`)
ElMessage.success(`已移除项目 ${proj.project_name}`)
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
async function toggleMonitor(proj, val) {
try {
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/`, {
monitor_enabled: val,
})
await loadOverview()
} catch (e) {
ElMessage.error('切换失败')
}
}
// === Project-level policies ===
async function attachProject(proj) {
const policyName = projectPolicyToAdd[proj.project_name]
if (!policyName) return
try {
const newPolicies = [...(proj.policies || []).map(p => p.name), policyName]
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/policies/`, {
policies: newPolicies,
})
ElMessage.success(`已添加 ${policyName}${proj.project_name}`)
projectPolicyToAdd[proj.project_name] = ''
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '添加失败')
}
}
async function detachProject(proj, row) {
await ElMessageBox.confirm(
`确定从 ${proj.project_name} 移除策略 "${row.name}" 吗?`,
'确认移除', { type: 'warning' }
)
try {
const newPolicies = (proj.policies || []).filter(p => p.name !== row.name).map(p => p.name)
await api.put(`/api/v1/iam-users/${userId}/projects/${proj.project_id}/policies/`, {
policies: newPolicies,
})
ElMessage.success(`已从 ${proj.project_name} 移除 ${row.name}`)
await loadOverview()
} catch (e) {
ElMessage.error(e.response?.data?.message || '移除失败')
}
}
onMounted(loadOverview)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<div style="max-width: 1000px; margin: 0 auto;">
<h2 style="margin-bottom: 16px;">我的 API Key</h2>
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
empty-text="暂无 API Key请联系管理员分配">
<el-table-column prop="project_name" label="所属项目" min-width="150" />
<el-table-column prop="key_name" label="名称/用途" min-width="180" />
<el-table-column label="API Key" min-width="260">
<template #default="{ row }">
<template v-if="revealedKeys[row.id]">
<code style="font-size: 13px; color: #409eff; word-break: break-all;">
{{ revealedKeys[row.id] }}
</code>
<el-button size="small" text @click="copyKey(revealedKeys[row.id])"
style="margin-left: 4px;">复制</el-button>
</template>
<template v-else>
<code style="color: #999;">{{ row.api_key_hint }}</code>
<el-button size="small" text type="primary" @click="handleReveal(row)"
style="margin-left: 4px;">查看</el-button>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
</el-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../../api'
const keys = ref([])
const loading = ref(false)
const revealedKeys = reactive({})
async function loadKeys() {
loading.value = true
try {
const { data } = await api.get('/api/v1/auth/iam/my-keys/')
keys.value = data
} catch (e) {
keys.value = []
} finally {
loading.value = false
}
}
async function handleReveal(row) {
try {
const { data } = await api.get(`/api/v1/auth/iam/my-keys/${row.id}/reveal/`)
revealedKeys[row.id] = data.api_key
} catch (e) {
ElMessage.error('获取 Key 失败')
}
}
async function copyKey(key) {
try {
await navigator.clipboard.writeText(key)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
onMounted(loadKeys)
</script>

View File

@ -0,0 +1,66 @@
<template>
<div style="max-width: 500px; margin: 0 auto;">
<h2 style="margin-bottom: 16px;">修改密码</h2>
<el-card>
<el-form label-width="100px">
<el-form-item label="原密码">
<el-input v-model="form.old_password" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="form.new_password" type="password" show-password
placeholder="至少6位" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="form.confirm_password" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChange" :loading="loading">修改密码</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import api from '../../api'
const router = useRouter()
const auth = useAuthStore()
const form = ref({ old_password: '', new_password: '', confirm_password: '' })
const loading = ref(false)
async function handleChange() {
if (!form.value.old_password || !form.value.new_password) {
ElMessage.warning('请填写完整')
return
}
if (form.value.new_password !== form.value.confirm_password) {
ElMessage.warning('两次密码不一致')
return
}
if (form.value.new_password.length < 6) {
ElMessage.warning('密码至少6位')
return
}
loading.value = true
try {
const { data } = await api.post('/api/v1/auth/iam/change-password/', {
old_password: form.value.old_password,
new_password: form.value.new_password,
})
ElMessage.success(data.message)
setTimeout(() => {
auth.logout()
router.push('/login')
}, 1500)
} catch (e) {
ElMessage.error(e.response?.data?.message || '修改失败')
} finally {
loading.value = false
}
}
</script>

View File

@ -23,14 +23,25 @@
<el-form-item label="监控间隔(秒)"> <el-form-item label="监控间隔(秒)">
<el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" /> <el-input-number v-model="config.monitor_interval_seconds" :min="60" :step="60" />
</el-form-item> </el-form-item>
<el-form-item label="飞书 Webhook URL"> <el-divider content-position="left">飞书通知</el-divider>
<el-input v-model="config.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..." /> <el-form-item label="飞书 App ID">
<el-input v-model="config.feishu_app_id" placeholder="cli_xxxxxxxx" />
</el-form-item> </el-form-item>
<el-form-item label="飞书通知手机号"> <el-form-item label="飞书 App Secret">
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号1,手机号2" /> <el-input v-model="config.feishu_app_secret" type="password" show-password
placeholder="飞书自建应用的密钥" />
</el-form-item>
<el-form-item label="告警接收手机号">
<el-input v-model="config.feishu_alert_mobiles" placeholder="手机号,多个用逗号分隔" />
<div style="font-size:12px;color:#999;margin-top:4px;">
填写飞书用户的手机号告警会以私信卡片发送
</div>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button> <el-button type="primary" @click="saveConfig" :loading="savingConfig">保存配置</el-button>
<el-button @click="testFeishu" :loading="testingFeishu" style="margin-left:12px;">
测试飞书通知
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@ -146,6 +157,20 @@ async function saveConfig() {
} }
} }
const testingFeishu = ref(false)
async function testFeishu() {
testingFeishu.value = true
try {
const { data } = await api.post('/api/v1/config/test-feishu/')
ElMessage.success(data.message)
} catch (e) {
ElMessage.error(e.response?.data?.message || '测试失败')
} finally {
testingFeishu.value = false
}
}
async function loadAccounts() { async function loadAccounts() {
loadingAccounts.value = true loadingAccounts.value = true
try { try {

View File

@ -17,7 +17,7 @@ cd C:\Airlabs_Project\AirGate\frontend
npm run dev npm run dev
``` ```
打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录。 打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录(首次登录后请立即修改密码)
### 2. 配置火山主账号 ### 2. 配置火山主账号
@ -35,132 +35,173 @@ npm run dev
| 配置项 | 说明 | 建议值 | | 配置项 | 说明 | 建议值 |
|--------|------|--------| |--------|------|--------|
| 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 | | 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 |
| 项目默认授权策略 | 添加项目时自动授权的策略 | ArkFullAccess,TOSFullAccess |
| 监控间隔(秒) | 定时查询消费的间隔 | 36001小时 | | 监控间隔(秒) | 定时查询消费的间隔 | 36001小时 |
| 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 | | 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 |
--- ---
## 二、日常操作 ## 二、管理员操作
### 给新部门开通子账号 ### 1. 创建子账号
**步骤 1创建子账号**
1. 左侧菜单 → **子账号管理** → 点 **创建子账号** 1. 左侧菜单 → **子账号管理** → 点 **创建子账号**
2. 填写: 2. 填写:
- **用户名**:英文,如 `dept_video` - **用户名**:英文,如 `dept_video`
- **显示名**:如 `视频部门` - **显示名**:如 `视频部门`
- **火山控制台密码**:填上(对方需要登录火山后台创建方舟 API Key - **手机号**:可选
- 其他选填 - **火山控制台密码**需包含大小写字母、数字和特殊字符8位以上火山密码策略要求
3. 点 **创建** 3. 点 **创建**
4. 弹窗显示 API 密钥 → **立即复制保存**SecretKey 仅显示一次) 4. 系统自动在火山创建 IAM 用户,并自动生成 `AirGate_Deny_{username}` 策略用于项目隔离
**步骤 2在火山控制台创建项目** ### 2. 权限管理
1. 登录 `console.volcengine.com`(你的主账号) 统一权限管理页面,一页展示所有权限信息:
2. 左上角项目管理 → 新建项目(如 `team-video-1`
**步骤 3在 AirGate 关联项目并授权** 1. 子账号管理 → 找到目标用户 → 点 **权限管理**
2. 页面分区:
- **全局策略**:当前用户挂载的全局策略列表(实时从火山查询)
- **项目级策略**:当前用户在各项目下挂载的策略(实时从火山查询)
- **关联项目**:管理用户关联的火山项目(添加/移除)
- **添加/移除策略**:为用户附加或移除 IAM 策略
3. 添加关联项目时自动更新 Deny 策略(将新项目加入白名单)
4. 移除关联项目时自动更新 Deny 策略(将项目从白名单移除)
1. 回到 AirGate → 子账号管理 → 找到刚创建的子账号 ### 3. 额度划拨
2. 点 **更多 → 项目管理**
3. 从下拉框选择刚创建的项目 → 点 **添加**
4. 系统自动在项目范围内授权 ArkFullAccess + TOSFullAccess
**步骤 4划拨额度**
1. 点子账号的 **划拨** 按钮
2. 选择「追加额度」,输入金额(如 100000
3. 填备注(如 `首次划拨`)→ 确认
**步骤 5告知对方**
发给对方以下信息:
- 火山控制台登录地址:`https://console.volcengine.com`
- 用户名:`dept_video`
- 密码:你设置的密码
- 登录后选择项目 `team-video-1`,进入方舟平台创建 Seedance 2.0 的 API Key
---
### 给子账号追加/扣减额度
1. 子账号管理 → 找到目标用户 → 点 **划拨** 1. 子账号管理 → 找到目标用户 → 点 **划拨**
2. 选择「追加额度」或「扣减额度」 2. 输入正数追加、负数扣减
3. 输入金额和备注 → 确认 3. **必须填写备注** → 确认
> 扣减有保护:总额度不能低于已消费金额 > 扣减有保护:总额度不能低于已消费金额
--- ### 4. 监控配置
### 给子账号增加新项目 1. 子账号管理 → 找到目标用户 → 点 **监控配置**
2. 可配置项:
- **阶梯告警**:自定义告警百分比阶梯(如 50,80,90未设置则使用全局默认值
- **消费监控开关**:开启/关闭该用户的消费监控
- **自动停用开关**:额度用尽时是否自动停用(关闭则只告警不停用)
- **Deny策略免除开关**:管理员自用账号可开启,免除 Deny 策略限制
1. 先在火山控制台创建新项目 ### 5. 停用/恢复账号
2. 回到 AirGate → 子账号管理 → 更多 → **项目管理**
3. 从下拉框选择新项目 → 添加(自动授权)
---
### 关闭某个项目的监测
1. 子账号管理 → 更多 → **项目管理**
2. 找到目标项目 → 关闭「监测」开关
3. 该项目的消费不再计入子账号的累计消费(不影响告警和停用判断)
---
### 手动停用/恢复子账号
**停用:** **停用:**
1. 子账号管理 → 更多 → **停用账号** 1. 子账号管理 → 更多 → **停用账号**
2. 确认后,子账号的控制台登录和所有 API Key 立即失效 2. 系统自动执行:
- 关闭火山控制台登录
- 停用所有 API Key
- 移除所有权限策略
- 保存策略快照(区分全局策略/项目级策略 + 登录状态),用于恢复
**恢复:** **恢复:**
1. 子账号管理 → 更多 → **恢复账号** 1. 子账号管理 → 更多 → **恢复账号**
2. 确认后,控制台登录和 API Key 立即恢复 2. 系统自动执行:
- 从快照还原所有权限策略(全局+项目级)
- 重建 `AirGate_Deny_{username}` 策略
- 按停用前状态恢复火山控制台登录(停用前已关闭的不会自动打开)
### 6. 火山登录开关
1. 子账号管理 → 更多 → **火山登录开关**
2. 独立于停用/恢复操作,可随时开启或关闭子账号的火山控制台登录权限
3. 同步调用火山 `UpdateLoginProfile` API
### 7. 编辑子账号信息
1. 子账号管理 → 找到目标用户 → 点 **编辑**
2. 可修改:显示名、手机号、邮箱
3. 修改后自动同步到火山引擎(调用 `UpdateUser` API
### 8. API Key 管理
1. 左侧菜单 → **API Key 管理** → 点 **录入 API Key**
2. 选择子账号、所属项目
3. 填写名称/用途、粘贴完整的 API Key
4. 点 **录入** → Key 加密存储
> API Key 采用手动录入方式。管理员在火山控制台创建 Key 后,将明文录入 AirGate。
> 原因:火山 `CreateApiKey` API 不返回 Key 明文,`ListApiKeys` 只返回脱敏值。
操作:查看明文 / 启用 / 停用 / 删除,可按子账号、项目筛选。
### 9. 系统管理
1. **修改密码**:左侧菜单 → 系统管理 → 修改密码
2. **管理员管理**(仅超级管理员):创建新管理员 / 启停 / 重置密码
3. **操作日志**:查看所有系统操作记录(含类型筛选)
--- ---
### 查看消费明细 ## 三、Deny策略说明
1. 左侧菜单 → **消费监控** ### 原理
2. 表格展示每个子账号的累计消费、额度、使用率
3. 点行首的 **展开箭头**,查看该子账号各项目的独立消费
4. 点 **刷新消费数据** 手动触发一次消费查询
5. 点 **查看主账号余额** 查看主账号的可用余额
> 消费数据来自火山 Billing API有 1-2 天延迟 AirGate 通过 Deny 策略实现项目隔离。原理:列出火山账号下所有项目,排除用户的白名单项目(已关联项目),对其余项目全部 Deny。
### 自动管理
- **创建子账号时**:自动生成 `AirGate_Deny_{username}` 策略
- **添加关联项目时**:自动更新所有子账号的 Deny 策略,将新项目加入白名单
- **移除关联项目时**:自动更新 Deny 策略,将项目从白名单移除
- **项目变动时**:刷新所有用户的 Deny 策略(确保新增的火山项目也被 Deny
### Deny策略免除
管理员自用账号可在监控配置中开启「Deny策略免除」开关免除 Deny 策略限制,允许访问所有项目。
### 为什么用 Deny 策略
火山 Open API 的 `AttachUserPolicy` 不支持 `Scope=Project` 参数2026-03-28 实测)。即使传了 `ProjectName` + `Scope=Project`,策略仍以 Global 方式挂载。项目级策略只能在火山控制台网页上手动操作。因此 AirGate 的项目隔离完全依赖 Deny 策略实现。
--- ---
### 查看告警记录 ## 四、子账号操作
1. 左侧菜单 → **告警记录** > 子账号使用独立的登录入口,不需要登录火山控制台。
2. 可按类型筛选:告警 / 自动停用 / 手动操作 / 错误
### 登录
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 可以直接使用。
--- ---
### 修改子账号的告警阶梯 ## 五、告警与自动停用机制
1. 子账号管理 → 更多 → **监控配置**
2. 修改告警阶梯百分比(如添加 95%
3. 开关消费监控 / 额度用尽自动停用
4. 保存
---
### 查看/管理子账号的权限策略
1. 子账号管理 → 更多 → **权限策略**
2. 上方可从下拉框选择策略手动附加
3. 已有策略列表中可点 **移除**
> 常规情况不需要手动管理权限,添加项目时会自动授权
---
## 三、告警与自动停用机制
``` ```
定时任务每小时运行一次 定时任务每小时运行一次
@ -183,10 +224,11 @@ npm run dev
- 每个阶梯只通知一次,不会重复 - 每个阶梯只通知一次,不会重复
- 追加或扣减额度后,告警状态自动重置 - 追加或扣减额度后,告警状态自动重置
- 「额度用尽自动停用」可在监控配置中关闭(只告警不停用) - 「额度用尽自动停用」可在监控配置中关闭(只告警不停用)
- 停用会同时移除所有权限策略,确保即使有活跃会话也立即失效
--- ---
## 、外部系统对接AirDrama ## 、外部系统对接AirDrama
AirGate 支持通过 API Key 认证供外部系统调用: AirGate 支持通过 API Key 认证供外部系统调用:
@ -199,14 +241,18 @@ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/iam-users/
curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/ curl -H "X-API-Key: 你的密钥" http://localhost:8101/api/v1/billing/overview/
``` ```
完整 API 列表见 [README.md](README.md) 或研究报告第 11 章。 完整 API 列表见研究报告第 11 章。
--- ---
## 五、注意事项 ## 七、火山API限制
1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量 1. **项目级策略Scope=Project通过API无法设置**需在火山控制台手动操作AirGate 的项目隔离完全依赖 Deny 策略
2. **SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了,务必保存 2. **火山控制台密码要求**需包含大小写字母、数字和特殊字符8位以上
3. **项目由你创建**:子账号没有创建项目的权限,需要新项目时在火山控制台创建后在 AirGate 关联 3. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量
4. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭 4. **IAM SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了
5. **加密密钥不要丢**`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的火山主账号 AK/SK 无法解密,需要重新配置 5. **方舟 API Key 由管理员录入**:火山 API 不返回 Key 明文,需要在火山控制台创建后手动录入 AirGate
6. **子账号不登录火山控制台**:所有操作通过 AirGate 完成,避免权限泄露
7. **项目由管理员创建**:子账号没有创建项目的权限,需要新项目时联系管理员
8. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭
9. **加密密钥不要丢**`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的密钥无法解密

View File

@ -1,7 +1,7 @@
# 火山引擎 IAM 子账号管控工具 -- 深度研究报告 # 火山引擎 IAM 子账号管控工具 -- 深度研究报告
> 研究日期2026-03-19 > 研究日期2026-03-19最后更新2026-03-20
> 目标:通过火山引擎 Open API实现对 IAM 子账号的全面管控,包括权限隔离、消费监控、告警、自动停用等功能。 > 目标:通过火山引擎 Open API构建 IAM 子账号的完整管控平台。子账号**不登录火山控制台**所有操作API Key 管理、消费查询等)均通过 AirGate 完成,实现权限隔离、消费监控、告警、自动停用等功能。
--- ---
@ -18,8 +18,10 @@
9. [项目管理与资源隔离](#9-项目管理与资源隔离) 9. [项目管理与资源隔离](#9-项目管理与资源隔离)
10. [SDK 与工具链](#10-sdk-与工具链) 10. [SDK 与工具链](#10-sdk-与工具链)
11. [可执行实施方案](#11-可执行实施方案) 11. [可执行实施方案](#11-可执行实施方案)
12. [限制与注意事项](#12-限制与注意事项) 12. [方舟 API Key 管理](#12-方舟-api-key-管理)
13. [参考文档](#13-参考文档) 13. [实测发现与架构决策](#13-实测发现与架构决策)
14. [限制与注意事项](#14-限制与注意事项)
15. [参考文档](#15-参考文档)
--- ---
@ -29,40 +31,54 @@
| 需求 | 实现方式 | 可行性 | | 需求 | 实现方式 | 可行性 |
|------|----------|--------| |------|----------|--------|
| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** | | 子账号不能看到主账号信息 | **子账号不登录火山控制台**,只登录 AirGate | **完全可行** |
| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** | | 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess全局无权限 | **完全可行** |
| 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** | | 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** |
| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** | | 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** |
| 子账号能查看自己的 API Key | 管理员在火山控制台创建 Key 后录入 AirGate子账号登录 AirGate 查看 | **完全可行** |
| 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警50%/80%/90%+ 飞书通知 | **完全可行** | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警50%/80%/90%+ 飞书通知 | **完全可行** |
| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** | | 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** |
| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** | | 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** |
### 1.2 架构图 ### 1.2 架构图
``` ```
┌──────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────
管控工具 (后端服务) AirGate子账号的唯一操作入口
│ │ │ │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ │ 管理员界面 子账号界面 │
│ │ IAM管理 │ │ 消费监控 │ │ 告警引擎 │ │ │ ┌───────────────┐ ┌──────────────┐ │
│ │ 模块 │ │ 模块 │ │ 模块 │ │ │ │ 子账号管理 │ │ 我的 API Key │ │
│ └────┬─────┘ └────┬─────┘ └────┬───────┘ │ │ │ 消费监控/告警 │ │ 我的消费 │ │
│ │ │ │ │ │ │ 额度划拨 │ │ 我的项目 │ │
│ ┌────▼─────────────▼─────────────▼───────┐ │ │ │ 权限配置 │ └──────────────┘ │
│ │ 系统管理 │ │
│ └───────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 火山引擎 Open API 调用层 │ │ │ │ 火山引擎 Open API 调用层 │ │
│ │ IAM API | Billing API | 方舟 API (Ark) │ │
│ │ (HMAC-SHA256 签名认证) │ │ │ │ (HMAC-SHA256 签名认证) │ │
│ └────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────
│ │ │ │ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐ ┌────▼────┐ ┌─────▼─────┐ ┌────▼─────
│ IAM API │ │Billing API│ │CloudMonitor │ IAM API │ │Billing API│ │ 方舟 API
│iam.vol..│ │billing.vol│ │open.vol.. │ │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**维度来实现。
--- ---
@ -530,7 +546,22 @@ iam_client.call("UpdateAccessKey", {
- 管理员可按需开关某些项目的监测(如测试项目不计费) - 管理员可按需开关某些项目的监测(如测试项目不计费)
- 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度 - 告警和自动停用基于所有开启项目的消费总和 vs 划拨额度
**消费查询方式:** 对每个开启监测的项目分别调用 `ListBillDetail`(按 Project 字段筛选),累加得出总消费。同时记录每个项目的独立消费,前端可展开查看明细。 **消费查询方式:** 使用 `ListSplitBillDetail`(分账账单)接口,按 Project 字段汇总。
> ⚠️ **重要发现2026-03-29 实测)**
> - `ListBillDetail`(账单明细)的 Project 字段对 Seedance 等按量付费产品显示为 `-`**按项目筛选不准确**
> - `ListSplitBillDetail`(分账账单)的 Project 字段能正确归属到项目,**与火山控制台分账账单页面一致**
> - AirGate 已切换为使用 `ListSplitBillDetail` 查询消费
**ListSplitBillDetail 验证结果2026-03**
| 项目 | API 查询 | 火山控制台 |
|------|---------|----------|
| int_dev_Airlabs | ¥24,058.78 | ¥24,014.14 |
| HAGOOT_DEV | ¥40.01 | ¥40.01 |
| zyc_test | ¥124.08 | - |
差异为查询时间点不同导致,数据一致。
### 6.4 账户余额查询 ### 6.4 账户余额查询
@ -868,13 +899,14 @@ iam.call("CreateUser", {
"MobilePhone": "+8618000000000" "MobilePhone": "+8618000000000"
}) })
# 开通控制台登录 # 注意:不开通控制台登录(子账号通过 AirGate 操作,不登录火山控制台)
iam.call("CreateLoginProfile", { # 如果确实需要开通控制台登录(不推荐),取消以下注释:
"UserName": "dept_a_user", # iam.call("CreateLoginProfile", {
"Password": "Initial@Pass123", # "UserName": "dept_a_user",
"LoginAllowed": "true", # "Password": "Initial@Pass123",
"PasswordResetRequired": "true" # "LoginAllowed": "true",
}) # "PasswordResetRequired": "true"
# })
# 创建 API 密钥(记录返回的 SecretAccessKey # 创建 API 密钥(记录返回的 SecretAccessKey
result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"}) result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"})
@ -1119,25 +1151,223 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛
# 项目列表 # 项目列表
GET /api/v1/projects/ # 从火山拉取项目列表 GET /api/v1/projects/ # 从火山拉取项目列表
# 方舟 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/ # 列出所有管理员
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 方舟 Open API 调研结果2026-03-20 实测)
方舟 API Key 管理接口使用 **POST + JSON body** 方式调用(与 IAM 的 GET + Query 不同)。
| 接口 | 说明 | 状态 |
|------|------|------|
| `ListApiKeys` | 列出项目下的 API Key返回脱敏值 `fedd****a052` | **已验证** |
| `CreateApiKey` | 创建 API Key**仅返回 ID不返回明文 Key** | **已验证** |
| `DeleteApiKey` | 删除 API Key | **已验证** |
| `GetApiKey` | 需要 `DurationSeconds`,疑似生成临时凭证,非查询明文 | **不适用** |
### 12.2 关键限制
> **方舟 API Key 的明文(完整 Key只有在火山控制台网页上创建时才会显示一次。通过 Open API 创建的 Key 无法获取明文,`ListApiKeys` 返回的永远是脱敏值。**
这意味着通过 API 自动化创建 Key 后,用户拿不到可用的 Key 值。
### 12.3 最终方案:管理员手动录入
鉴于上述限制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 KeyAES-256
- api_key_hint: 脱敏显示前4后4
- status: active / disabled
- created_by: 谁录入的
- created_at / updated_at
```
### 12.4 安全设计
- **加密存储**API Key 使用与主账号 AK/SK 相同的 `AIRGATE_ENCRYPTION_KEY` 加密存储
- **按需解密**:子账号查看 Key 时解密展示,页面关闭后不保留
- **权限隔离**:子账号只能看到绑定给自己的 Key管理员能看到所有
- **操作审计**Key 的录入、查看、停用、删除均记录到操作日志
### 12.5 火山控制台操作保留
以下操作仍需管理员在火山控制台完成(无法通过 API 替代):
| 操作 | 在哪里做 | 频率 |
|------|----------|------|
| 创建火山项目 | 火山控制台 | 低(新团队入驻时) |
| 在项目下开通模型端点 | 火山控制台 | 低(新模型接入时) |
| 创建方舟 API Key | 火山控制台 | 低(按需创建) |
| **其他所有操作** | **AirGate** | 日常 |
---
## 13. 实测发现与架构决策
### 13.1 火山控制台权限隔离问题2026-03-20
| 测试场景 | 结果 |
|----------|------|
| 项目级 `ArkFullAccess` + 无全局权限 | 无法进入方舟控制台页面,提示需要 `ArkReadOnlyAccess` |
| 全局 `ArkReadOnlyAccess` | 能进入控制台,但能看到**所有项目**的 API Key |
| 全局 `ArkExperienceAccess` | 能进入体验中心,能看到所有项目的内容 |
| 停用账号但子账号未刷新页面 | 子账号仍可在体验中心生成视频 |
| 停用账号 + 移除所有策略 | 子账号刷新页面后立即失效 |
**结论**:火山控制台无法实现项目级的视图隔离。要实现"子账号只看到自己项目"必须在应用层AirGate控制。
### 13.1.2 火山Open API不支持Scope=Project2026-03-28 实测)
| 测试场景 | 结果 |
|----------|------|
| `AttachUserPolicy``Scope=Project` + `ProjectName=xxx` | 策略仍以 **Global** 方式挂载,`ListAttachedUserPolicies` 查询显示 Scope=Global |
| 火山控制台网页上手动「限制到项目资源」 | 策略正确以 Project 方式挂载 |
**结论**:火山 Open API 的 `AttachUserPolicy` 即使传了 `Scope=Project` + `ProjectName` 参数,策略仍然以 Global 方式挂载。项目级策略Scope=Project只能在火山控制台网页上手动操作点击「限制到项目资源」按钮。因此 AirGate 无法通过 API 实现项目级授权,项目隔离完全依赖 Deny 策略实现。
### 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 最终权限方案:全局授权 + Deny策略隔离
```
子账号在火山引擎上的权限(由 AirGate 自动管理):
核心思路:全局授权 + Deny策略隔离
由于火山 Open API 不支持 Scope=Project见 13.1.2
所有策略以全局方式挂载,再通过 Deny 策略限定可访问的项目范围。
全局权限:
├── AccessKeySelfManageAccess ← 管理自己的 AK/SK可选
└── AirGate_Deny_{username} ← 自定义 Deny 策略,禁止访问非授权项目
使用 NotResource 限定只能访问已关联的项目
全局业务权限(通过 AttachUserPolicy全局生效
├── ArkFullAccess ← 方舟操作权限(全局,但被 Deny 策略限定到白名单项目)
└── TOSFullAccess ← TOS 操作权限(按需)
⚠️ 重要发现2026-03-28 实测):
火山 Open API 的 AttachUserPolicy 不支持 Scope=Project 参数。
即使传了 ProjectName + Scope=Project策略仍然以 Global 方式挂载。
项目级限制只能在火山控制台网页上手动操作(「限制到项目资源」按钮)。
因此 AirGate 的项目隔离完全依赖 Deny 策略实现。
火山控制台登录默认关闭AirGate 提供开关可随时切换)
Deny 策略自动管理(项目隔离的唯一可靠手段):
- 创建子账号时 → 自动创建 AirGate_Deny_{username} 策略
- 添加关联项目时 → 自动更新 Deny 策略,将新项目加入白名单
- 移除关联项目时 → 自动更新 Deny 策略,将项目从白名单移除
- 火山项目变动时 → 刷新所有用户的 Deny 策略
- Deny 策略列出所有非白名单项目并明确拒绝
- 策略命名AirGate_Deny_{username}
- 管理员自用账号可免除 Deny 策略(监控配置中开启)
```
子账号**不能也不需要**登录火山控制台。所有操作通过 AirGate 完成:
| 操作 | 在哪里做 |
|------|----------|
| 查看自己的 API Key | AirGate管理员录入子账号查看 |
| 查看消费 | 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 个项目,消费=开启监测的项目之和) | | IAM 子账号无独立计费 | 所有费用归主账号,通过多项目聚合追踪(子账号关联 N 个项目,消费=开启监测的项目之和) |
| Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 | | Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 |
| 每用户最多 2 个 API 密钥 | 无法创建更多 | | 每用户最多 2 个 IAM AccessKey | IAM 级别的 AK/SK 最多 2 对(方舟 API Key 无此限制) |
| SecretKey 仅返回一次 | 创建后立即保存 | | IAM SecretKey 仅返回一次 | 创建后立即保存 |
| Billing API QPS 限制 5 | 批量查询需注意限流 | | Billing API QPS 限制 5 | 批量查询需注意限流 |
| Ark 推理限额无公开 API | 目前仅支持控制台操作 | | **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 |
| **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 |
| **方舟 CreateApiKey 不返回 Key 明文** | 只返回 IDListApiKeys 返回脱敏值明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 |
| 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 |
| 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 |
| 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 |
### 12.2 安全建议 ### 14.2 安全建议
1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务 1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务
2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥 2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥
@ -1145,7 +1375,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
4. **显式 Deny 策略优先**,防止权限漏洞 4. **显式 Deny 策略优先**,防止权限漏洞
5. **监控日志**,使用 CloudTrail 审计 API 调用 5. **监控日志**,使用 CloudTrail 审计 API 调用
### 12.3 消费监控的精确度问题 ### 14.3 消费监控的精确度问题
由于账单数据有 1-2 天延迟消费监控存在滞后。AirGate 的应对策略: 由于账单数据有 1-2 天延迟消费监控存在滞后。AirGate 的应对策略:
- **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90] - **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90]
@ -1155,7 +1385,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表
--- ---
## 13. 参考文档 ## 15. 参考文档
### 官方文档 ### 官方文档

View File

@ -2,6 +2,26 @@
--- ---
## v0.5.0 (2026-03-28 ~ 2026-03-29)
### 权限管理重构
- feat: 统一权限管理页面(全局策略 + 项目级策略 + 关联项目 + 添加/移除策略,一页展示)
- feat: Deny策略自动化项目隔离—— 创建子账号时自动生成 `AirGate_Deny_{username}`,添加/移除项目时自动更新
- feat: Deny策略免除开关管理员自用账号可在监控配置中开启
- fix: 火山API不支持Scope=Project2026-03-28实测改用全局授权 + Deny策略实现项目隔离
- fix: 项目变动刷新所有用户Deny策略
- fix: 前后端权限显示一致(实时从火山查询,不再依赖本地缓存)
### 账号管理增强
- feat: 火山控制台登录开关(独立于停用/恢复,随时切换)
- feat: 编辑子账号信息(显示名、手机号、邮箱,修改后同步火山 `UpdateUser` API
- feat: 创建子账号密码校验(火山密码策略:大小写字母 + 数字 + 特殊字符8位以上
- fix: 停用/恢复保存策略快照(区分全局策略/项目级策略 + 登录状态,恢复时精确还原)
- fix: 同步不再把火山登录关闭当成账号停用
- fix: 检测幽灵LoginProfileCreateDate=1970火山API已知问题
---
## v0.4.0 (2026-03-20) ## v0.4.0 (2026-03-20)
### UI 优化 ### UI 优化