diff --git a/API方案.md b/API方案.md new file mode 100644 index 0000000..25b528e --- /dev/null +++ b/API方案.md @@ -0,0 +1,33 @@ +方案一:火山 IAM 子账号(推荐) +思路:给队友创建火山子用户,让他直连火山 API。 + +给他的东西 +用途 给什么 +Assets API 子账号的 AK/SK +Seedance 调用 子账号的 Ark API Key + 专用接入点(只绑 Seedance 2.0) +对账 控制台登录密码 +项目标识 ProjectName: "int_dev_Airlabs" +权限范围 +✅ Ark 模型调用、Assets 素材管理、费用中心(只读) +❌ IAM 管理、其他云服务、充值/支付 +优缺点 +✅ 零开发量,控制台几分钟搞定 +✅ 他能自己对账 +✅ 权限可控,随时可禁用/删除 +✅ 互不影响,你的服务挂了不影响他 +❌ 他能直接接触火山资源(但权限受限) +方案二:后端转发 +思路:你的后端包一层,队友只调你的接口,AK/SK 不出服务器。 + +给他的东西 +用途 给什么 +所有调用 后端地址 + 账号密码(JWT 认证) +优缺点 +✅ 队友什么密钥都不需要,最安全 +✅ 你能完全掌控调用行为 +❌ 要写新接口把火山 API 都包一遍 +❌ 对账要你自己做用量统计页面 +❌ 多一跳,依赖你的服务稳定性 +❌ 火山 API 变更你得跟着维护 +一句话结论 +队友是自己人 → 方案一,省事;对外卖服务/不信任对方 → 方案二,可控。 \ No newline at end of file diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 4ca71f5..c5a0a1c 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -13,6 +13,10 @@ class UserInfoSerializer(serializers.Serializer): is_active = serializers.BooleanField() date_joined = serializers.DateTimeField() last_login = serializers.DateTimeField() + role = serializers.SerializerMethodField() + + def get_role(self, obj): + return 'admin' class ChangePasswordSerializer(serializers.Serializer): diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 23c181a..3ee1e49 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -10,4 +10,11 @@ urlpatterns = [ path('admins/create/', views.admin_create_view), path('admins//toggle/', views.admin_toggle_view), path('admins//reset-password/', views.admin_reset_password_view), + + # Sub-account (IAM user) login + path('iam/login/', views.iam_login_view), + path('iam/me/', views.iam_me_view), + path('iam/my-keys/', views.iam_my_keys_view), + path('iam/my-keys//reveal/', views.iam_my_key_reveal_view), + path('iam/change-password/', views.iam_change_password_view), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index e5f7d67..ce1685f 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,6 +1,6 @@ from django.contrib.auth import authenticate from rest_framework import status -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken @@ -195,3 +195,214 @@ def admin_reset_password_view(request, pk): ) return Response({'message': f'已重置 {user.username} 的密码'}) + + +# ==================== Sub-account (IAM User) Login ==================== + +@api_view(['POST']) +@permission_classes([AllowAny]) +def iam_login_view(request): + """子账号登录 AirGate""" + username = request.data.get('username', '') + password = request.data.get('password', '') + + if not username or not password: + return Response({'error': 'missing', 'message': '请输入用户名和密码'}, + status=status.HTTP_400_BAD_REQUEST) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(username=username) + except IAMUser.DoesNotExist: + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + if not iam_user.login_enabled: + return Response({'error': 'login_disabled', 'message': '此账号未开通 AirGate 登录'}, + status=status.HTTP_403_FORBIDDEN) + + if iam_user.status != IAMUser.Status.ACTIVE: + return Response({'error': 'user_disabled', 'message': '账号已停用'}, + status=status.HTTP_403_FORBIDDEN) + + if not iam_user.check_login_password(password): + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + # Generate JWT token with iam_user info (use a dummy admin user for simplejwt) + import jwt + from django.conf import settings + from datetime import datetime, timedelta, timezone + payload = { + 'token_type': 'access', + 'iam_user_id': iam_user.id, + 'username': iam_user.username, + 'role': 'iam_user', + 'exp': datetime.now(timezone.utc) + timedelta(hours=24), + 'iat': datetime.now(timezone.utc), + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + + return Response({ + 'access': token, + 'user': { + 'id': iam_user.id, + 'username': iam_user.username, + 'display_name': iam_user.display_name, + 'role': 'iam_user', + } + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_me_view(request): + """子账号获取自身信息(通过 JWT token 中的 iam_user_id)""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return Response({'error': 'token_expired'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(pk=payload['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + from apps.monitor.serializers import IAMUserSerializer + return Response({ + 'role': 'iam_user', + 'user': IAMUserSerializer(iam_user).data, + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_keys_view(request): + """子账号查看自己的 API Key""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from apps.monitor.serializers import ArkApiKeySerializer + keys = ArkApiKey.objects.filter(iam_user_id=payload['iam_user_id']) + return Response(ArkApiKeySerializer(keys, many=True).data) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_key_reveal_view(request, pk): + """子账号查看自己的 API Key 明文""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from utils.crypto import decrypt + try: + key = ArkApiKey.objects.get(pk=pk, iam_user_id=payload['iam_user_id']) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + return Response({ + 'api_key': decrypt(key.api_key_enc), + 'key_name': key.key_name, + 'project_name': key.project_name, + }) + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_change_password_view(request): + """子账号修改自己的 AirGate 登录密码""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(pk=payload['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + old_password = request.data.get('old_password', '') + new_password = request.data.get('new_password', '') + + if not old_password or not new_password: + return Response({'error': 'missing', 'message': '请输入原密码和新密码'}, + status=status.HTTP_400_BAD_REQUEST) + + if not iam_user.check_login_password(old_password): + return Response({'error': 'wrong_password', 'message': '原密码错误'}, + status=status.HTTP_400_BAD_REQUEST) + + if len(new_password) < 6: + return Response({'error': 'weak_password', 'message': '密码至少6位'}, + status=status.HTTP_400_BAD_REQUEST) + + iam_user.set_login_password(new_password) + iam_user.save(update_fields=['login_password_hash']) + + from apps.monitor.models import AlertRecord + AlertRecord.objects.create( + iam_user=iam_user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"子账号 {iam_user.username} 修改 AirGate 密码", + content=f"操作人: {iam_user.username}(自行修改)", + ) + + return Response({'message': '密码修改成功,请重新登录'}) diff --git a/backend/apps/monitor/migrations/0007_arkapikey.py b/backend/apps/monitor/migrations/0007_arkapikey.py new file mode 100644 index 0000000..96e1400 --- /dev/null +++ b/backend/apps/monitor/migrations/0007_arkapikey.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0006_iamuser_saved_policies_on_disable'), + ] + + operations = [ + migrations.CreateModel( + name='ArkApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('project_name', models.CharField(max_length=200, verbose_name='所属项目')), + ('key_name', models.CharField(max_length=200, verbose_name='Key 名称/用途')), + ('api_key_enc', models.TextField(verbose_name='API Key(加密)')), + ('api_key_hint', models.CharField(blank=True, max_length=30, verbose_name='API Key 提示(脱敏)')), + ('status', models.CharField(choices=[('active', '启用'), ('disabled', '停用')], default='active', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_by', models.CharField(blank=True, max_length=100, verbose_name='录入人')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('iam_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ark_keys', to='monitor.iamuser')), + ], + options={ + 'verbose_name': '方舟 API Key', + 'verbose_name_plural': '方舟 API Key', + 'db_table': 'airgate_ark_api_key', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py new file mode 100644 index 0000000..411e4e1 --- /dev/null +++ b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0007_arkapikey'), + ] + + operations = [ + migrations.AddField( + model_name='iamuser', + name='login_enabled', + field=models.BooleanField(default=False, verbose_name='允许登录 AirGate'), + ), + migrations.AddField( + model_name='iamuser', + name='login_password_hash', + field=models.CharField(blank=True, max_length=256, verbose_name='AirGate 登录密码哈希'), + ), + ] diff --git a/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py b/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py new file mode 100644 index 0000000..e00c99b --- /dev/null +++ b/backend/apps/monitor/migrations/0009_iamuser_volc_login_allowed.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2026-03-28 11:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0008_iamuser_login_enabled_iamuser_login_password_hash'), + ] + + operations = [ + migrations.AddField( + model_name='iamuser', + name='volc_login_allowed', + field=models.BooleanField(default=False, verbose_name='允许登录火山控制台'), + ), + ] diff --git a/backend/apps/monitor/migrations/0010_iamuser_deny_policy_exempt.py b/backend/apps/monitor/migrations/0010_iamuser_deny_policy_exempt.py new file mode 100644 index 0000000..c29ac6e --- /dev/null +++ b/backend/apps/monitor/migrations/0010_iamuser_deny_policy_exempt.py @@ -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 策略'), + ), + ] diff --git a/backend/apps/monitor/migrations/0011_globalconfig_feishu_app_id_and_more.py b/backend/apps/monitor/migrations/0011_globalconfig_feishu_app_id_and_more.py new file mode 100644 index 0000000..8ea0405 --- /dev/null +++ b/backend/apps/monitor/migrations/0011_globalconfig_feishu_app_id_and_more.py @@ -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='监控间隔(秒)'), + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index fd1ae68..c3f2cd2 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -1,4 +1,5 @@ from decimal import Decimal +from django.contrib.auth.hashers import make_password, check_password from django.db import models @@ -37,6 +38,11 @@ class IAMUser(models.Model): phone = models.CharField('手机号', max_length=20, blank=True) status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN) + # AirGate 本地登录密码(子账号用来登录 AirGate,与火山控制台无关) + login_password_hash = models.CharField('AirGate 登录密码哈希', max_length=256, blank=True) + login_enabled = models.BooleanField('允许登录 AirGate', default=False) + volc_login_allowed = models.BooleanField('允许登录火山控制台', default=False) + # Access keys (stored as JSON list of AK IDs, not secrets) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) @@ -56,6 +62,9 @@ class IAMUser(models.Model): triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True, help_text='记录已通知过的百分比,划拨新额度时自动重置') + deny_policy_exempt = models.BooleanField('免除 Deny 策略', default=False, + help_text='开启后不生成项目隔离 Deny 策略(适用于管理员自用账号)') + # --- 停用时保存的策略快照(恢复时自动加回) --- saved_policies_on_disable = models.JSONField('停用时保存的策略', default=list, blank=True, help_text='停用时自动移除的策略列表,恢复时加回') @@ -73,6 +82,12 @@ class IAMUser(models.Model): def __str__(self): return f"{self.display_name or self.username} ({self.status})" + def set_login_password(self, raw_password): + self.login_password_hash = make_password(raw_password) + + def check_login_password(self, raw_password): + return check_password(raw_password, self.login_password_hash) + @property def remaining_quota(self): """剩余额度""" @@ -116,6 +131,34 @@ class IAMUserProject(models.Model): return f"{self.project_name} ({status}) ¥{self.current_spending}" +class ArkApiKey(models.Model): + """方舟 API Key(管理员手动录入,加密存储)""" + + class Status(models.TextChoices): + ACTIVE = 'active', '启用' + DISABLED = 'disabled', '停用' + + iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='ark_keys') + project_name = models.CharField('所属项目', max_length=200) + key_name = models.CharField('Key 名称/用途', max_length=200) + api_key_enc = models.TextField('API Key(加密)') + api_key_hint = models.CharField('API Key 提示(脱敏)', max_length=30, blank=True) + status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.ACTIVE) + remark = models.TextField('备注', blank=True) + created_by = models.CharField('录入人', max_length=100, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '方舟 API Key' + verbose_name_plural = '方舟 API Key' + db_table = 'airgate_ark_api_key' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.iam_user.username}/{self.project_name}: {self.key_name} ({self.api_key_hint})" + + class QuotaAllocation(models.Model): """额度划拨记录""" iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations') @@ -141,9 +184,13 @@ class GlobalConfig(models.Model): help_text='如 [50, 80, 90]') default_project_policies = models.JSONField('添加项目时自动授权的策略', default=list, blank=True, help_text='如 ["ArkFullAccess", "TOSFullAccess"]') - monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=3600) - feishu_webhook_url = models.URLField('飞书 Webhook URL', max_length=500, blank=True) - feishu_alert_mobiles = models.CharField('飞书通知手机号(逗号分隔)', max_length=500, blank=True) + monitor_interval_seconds = models.IntegerField('监控间隔(秒)', default=60) + feishu_app_id = models.CharField('飞书 App ID', max_length=200, 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) class Meta: diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index fb9bab5..fd618a4 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import IAMUser, IAMUserProject, VolcAccount, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey class VolcAccountSerializer(serializers.ModelSerializer): @@ -41,6 +41,8 @@ class IAMUserSerializer(serializers.ModelSerializer): 'alert_thresholds', 'triggered_alerts', 'effective_alert_thresholds', 'projects', 'monitored_project_count', + 'login_enabled', 'volc_login_allowed', + 'deny_policy_exempt', 'remark', 'created_at', 'updated_at', ] read_only_fields = ['user_id', 'access_key_ids', 'status', @@ -76,6 +78,7 @@ class IAMUserConfigSerializer(serializers.Serializer): ) monitor_enabled = serializers.BooleanField(required=False) auto_disable_enabled = serializers.BooleanField(required=False) + deny_policy_exempt = serializers.BooleanField(required=False) class IAMUserProjectAddSerializer(serializers.Serializer): @@ -118,7 +121,8 @@ class GlobalConfigSerializer(serializers.ModelSerializer): 'default_alert_thresholds', 'default_project_policies', 'monitor_interval_seconds', - 'feishu_webhook_url', 'feishu_alert_mobiles', + 'feishu_app_id', 'feishu_app_secret', + 'feishu_alert_mobiles', '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): total_users = serializers.IntegerField() active_users = serializers.IntegerField() diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 7acc38f..a6cf0af 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -17,15 +17,20 @@ urlpatterns = [ path('iam-users/import/', views.iam_user_import_view), path('iam-users//', views.iam_user_detail_view), path('iam-users//update/', views.iam_user_update_view), + path('iam-users//edit-profile/', views.iam_user_edit_profile_view), + path('iam-users//toggle-volc-login/', views.iam_user_toggle_volc_login_view), + path('iam-users//set-login/', views.iam_user_set_login_view), path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), path('iam-users//policies/', views.iam_user_policies_view), + path('iam-users//policies/overview/', views.iam_user_policies_overview_view), path('iam-users//policies/attach/', views.iam_user_attach_policy_view), path('iam-users//policies/detach/', views.iam_user_detach_policy_view), # IAM user projects (multi-project) path('iam-users//projects/', views.iam_user_project_list_view), path('iam-users//projects/add/', views.iam_user_project_add_view), path('iam-users//projects//', views.iam_user_project_update_view), + path('iam-users//projects//policies/', views.iam_user_project_policies_view), path('iam-users//projects//delete/', views.iam_user_project_delete_view), path('iam-users//projects/toggle-all/', views.iam_user_project_toggle_all_view), @@ -40,10 +45,18 @@ urlpatterns = [ # Global config path('config/', views.global_config_view), + path('config/test-feishu/', views.test_feishu_view), # Alerts path('alerts/', views.alert_list_view), # Projects 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//', views.ark_key_update_view), + path('ark-keys//delete/', views.ark_key_delete_view), + path('ark-keys//reveal/', views.ark_key_reveal_view), ] diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 31f1199..d2f813c 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -12,9 +12,10 @@ from rest_framework.response import Response from utils.crypto import encrypt, decrypt, make_hint from utils.iam_service import IAMService, ProjectService from utils.billing_service import BillingService +from utils.ark_service import ArkService from utils.volcengine_client import VolcengineAPIError -from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation +from .models import VolcAccount, IAMUser, IAMUserProject, GlobalConfig, AlertRecord, SpendingRecord, QuotaAllocation, ArkApiKey from .serializers import ( VolcAccountSerializer, VolcAccountCreateSerializer, IAMUserSerializer, IAMUserCreateSerializer, IAMUserImportSerializer, @@ -23,6 +24,7 @@ from .serializers import ( QuotaAllocateSerializer, QuotaAllocationSerializer, GlobalConfigSerializer, AlertRecordSerializer, + ArkApiKeySerializer, ArkApiKeyCreateSerializer, DashboardSerializer, ) @@ -42,6 +44,35 @@ def _get_volc_account(volc_id=None): return account, ak, sk +def _update_deny_policy(user): + """更新子账号的 Deny 策略,只允许访问已关联的项目""" + account, ak, sk = _get_volc_account(user.volc_account_id) + if not ak: + return + svc = IAMService(ak, sk) + + 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 ==================== @api_view(['GET']) @@ -153,6 +184,7 @@ def iam_user_sync_view(request): svc = IAMService(ak, sk) imported = [] + volc_usernames = set() offset = 0 while True: @@ -168,6 +200,7 @@ def iam_user_sync_view(request): for u in users: username = u.get("UserName", "") + volc_usernames.add(username) obj, created = IAMUser.objects.update_or_create( volc_account=account, username=username, @@ -188,13 +221,32 @@ def iam_user_sync_view(request): except Exception: 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: profile = svc.get_login_profile(username) - login_allowed = profile.get("Result", {}).get("LoginProfile", {}).get("LoginAllowed", True) - obj.status = IAMUser.Status.ACTIVE if login_allowed else IAMUser.Status.DISABLED + lp = profile.get("Result", {}).get("LoginProfile", {}) + create_date = lp.get("CreateDate", "") + if create_date.startswith("1970") or create_date.startswith("0001"): + obj.volc_login_allowed = False + else: + obj.volc_login_allowed = lp.get("LoginAllowed", False) except Exception: - obj.status = IAMUser.Status.UNKNOWN + obj.volc_login_allowed = False obj.save() @@ -203,10 +255,22 @@ def iam_user_sync_view(request): if offset >= total: 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() + msg = f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个' + if removed: + msg += f',清理 {len(removed)} 个已删除用户({", ".join(removed)})' return Response({ - 'message': f'同步完成,共 {total_count} 个用户,新导入 {len(imported)} 个', + 'message': msg, 'imported': imported, + 'removed': removed, 'total': total_count, }) @@ -250,7 +314,18 @@ def iam_user_create_view(request): try: svc.create_login_profile(d['username'], password) result_info['login_enabled'] = True + result_info['volc_login_allowed'] = True except VolcengineAPIError as e: + if 'InvalidPassword' in str(e): + # Rollback: delete the user we just created + try: + svc.client.call("DeleteUser", {"UserName": d['username']}) + except Exception: + pass + return Response({ + 'message': f'火山控制台密码不符合要求(需包含大小写字母、数字和特殊字符,至少8位)', + 'detail': str(e), + }, status=status.HTTP_400_BAD_REQUEST) result_info['login_error'] = str(e) # 3. Create access key @@ -280,6 +355,7 @@ def iam_user_create_view(request): phone=d.get('phone', ''), status=IAMUser.Status.ACTIVE, access_key_ids=[result_info.get('access_key_id', '')] if result_info.get('access_key_id') else [], + volc_login_allowed=result_info.get('volc_login_allowed', False), ) # 6. Auto-add project if specified @@ -291,6 +367,9 @@ def iam_user_create_view(request): monitor_enabled=True, ) + # 7. Create Deny policy (project isolation) + refresh all users + _refresh_all_deny_policies() + AlertRecord.objects.create( iam_user=obj, alert_type=AlertRecord.AlertType.MANUAL, @@ -361,12 +440,142 @@ def iam_user_update_view(request, pk): serializer = IAMUserConfigSerializer(data=request.data, partial=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(): setattr(user, field, value) user.save() + + if deny_changed: + _update_deny_policy(user) + 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']) def iam_user_disable_view(request, pk): """停用子账号""" @@ -387,6 +596,8 @@ def iam_user_disable_view(request, pk): # 2. 移除所有权限策略并保存快照(恢复时加回) saved_policies = [] detach_errors = [] + + # 2a. 全局策略 try: resp = svc.list_attached_user_policies(user.username) policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) @@ -395,15 +606,37 @@ def iam_user_disable_view(request, pk): ptype = p.get("PolicyType", "") try: 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: - 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: + pass + user.status = IAMUser.Status.DISABLED + # 在策略快照里记住停用前的火山登录状态 + saved_policies.append({"_volc_login_was": user.volc_login_allowed}) user.saved_policies_on_disable = saved_policies - user.save(update_fields=['status', 'saved_policies_on_disable']) + user.volc_login_allowed = False + user.save(update_fields=['status', 'saved_policies_on_disable', 'volc_login_allowed']) policy_count = len(saved_policies) error_info = f",移除失败: {detach_errors}" if detach_errors else "" @@ -437,23 +670,39 @@ def iam_user_enable_view(request, pk): svc = IAMService(ak, sk) try: - # 1. 恢复控制台 + API 密钥 - svc.enable_user(user.username) + # 从快照中提取停用前的火山登录状态 + saved_policies = user.saved_policies_on_disable or [] + restore_login = False + actual_policies = [] + for p in saved_policies: + if "_volc_login_was" in p: + restore_login = p["_volc_login_was"] + else: + actual_policies.append(p) - # 2. 重新附加停用时保存的策略 + # 1. 恢复 API 密钥 + 控制台(按停用前状态) + svc.enable_user(user.username, restore_login=restore_login) + + # 2. 重新附加停用时保存的策略(按原始位置:全局或项目级) restored_count = 0 restore_errors = [] - saved_policies = user.saved_policies_on_disable or [] - for p in saved_policies: + for p in actual_policies: try: - svc.attach_user_policy(user.username, p["name"], p["type"]) + 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"]) restored_count += 1 except VolcengineAPIError as restore_err: restore_errors.append(f"{p['name']}: {restore_err}") + # 3. 重建 Deny 策略(项目隔离) + _update_deny_policy(user) + user.status = IAMUser.Status.ACTIVE user.saved_policies_on_disable = [] - user.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 "" AlertRecord.objects.create( @@ -473,6 +722,84 @@ def iam_user_enable_view(request, pk): @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): """查看子账号的权限策略""" try: @@ -487,8 +814,15 @@ def iam_user_policies_view(request, pk): svc = IAMService(ak, sk) try: resp = svc.list_attached_user_policies(user.username) - policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) - return Response({'policies': policies}) + all_policies = resp.get("Result", {}).get("AttachedPolicyMetadata", []) + # 只返回全局策略(过滤项目级的) + 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: return Response({'error': 'api_error', 'message': str(e)}, status=status.HTTP_502_BAD_GATEWAY) @@ -564,12 +898,38 @@ def iam_user_detach_policy_view(request, pk): @api_view(['GET']) def iam_user_project_list_view(request, pk): - """查看子账号关联的项目列表""" + """查看子账号关联的项目列表(实时从火山同步项目级策略)""" try: user = IAMUser.objects.get(pk=pk) except IAMUser.DoesNotExist: return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + 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) @@ -615,6 +975,9 @@ def iam_user_project_add_view(request, pk): obj.attached_policies = attached obj.save(update_fields=['attached_policies']) + # 更新所有子账号的 Deny 策略(新项目需要加入其他人的拒绝列表) + _refresh_all_deny_policies() + AlertRecord.objects.create( iam_user=user, alert_type=AlertRecord.AlertType.MANUAL, @@ -648,6 +1011,82 @@ def iam_user_project_update_view(request, pk, pid): return Response(IAMUserProjectSerializer(project).data) +@api_view(['PUT']) +def iam_user_project_policies_view(request, pk, pid): + """更新项目级授权策略(增量对比:移除旧的、添加新的)""" + try: + project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk) + user = project.iam_user + except IAMUserProject.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + new_policies = request.data.get('policies', []) + + 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']) def iam_user_project_delete_view(request, pk, pid): """移除关联项目:回收权限 + 移出监测""" @@ -682,6 +1121,9 @@ def iam_user_project_delete_view(request, pk, pid): project.delete() + # 更新所有子账号的 Deny 策略 + _refresh_all_deny_policies() + result = {'message': f'已移除项目 {name},已回收权限: {detached}'} if detach_errors: result['detach_errors'] = detach_errors @@ -825,6 +1267,28 @@ def global_config_view(request): 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 ==================== @api_view(['GET']) @@ -861,3 +1325,128 @@ def project_list_view(request): except VolcengineAPIError as e: return Response({'error': 'api_error', 'message': str(e)}, status=status.HTTP_502_BAD_GATEWAY) + + +# ==================== Ark API Key Management (手动录入模式) ==================== + +@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, + }) diff --git a/backend/utils/ark_service.py b/backend/utils/ark_service.py new file mode 100644 index 0000000..2283394 --- /dev/null +++ b/backend/utils/ark_service.py @@ -0,0 +1,42 @@ +"""方舟(Ark)API Key 管理服务""" + +import logging +from .volcengine_client import VolcengineClient, VolcengineAPIError, get_ark_client + +logger = logging.getLogger(__name__) + + +class ArkService: + """方舟 API Key 管理""" + + def __init__(self, ak: str, sk: str): + self.client = get_ark_client(ak, sk) + + def list_api_keys(self, project_name: str, page_size: int = 100, page_number: int = 1) -> dict: + """列出项目下的 API Key""" + return self.client.call_json("ListApiKeys", { + "ProjectName": project_name, + "PageSize": page_size, + "PageNumber": page_number, + }) + + def create_api_key(self, project_name: str, name: str, resource_type: str = "all") -> dict: + """在项目下创建 API Key""" + return self.client.call_json("CreateApiKey", { + "ProjectName": project_name, + "Name": name, + "ResourceInstances": [{"ResourceId": "*", "ResourceType": resource_type}], + }) + + def delete_api_key(self, api_key_id: int) -> dict: + """删除 API Key""" + return self.client.call_json("DeleteApiKey", { + "Id": api_key_id, + }) + + def update_api_key_status(self, api_key_id: int, status: str) -> dict: + """启用/停用 API Key (status: Active / Inactive)""" + return self.client.call_json("UpdateApiKey", { + "Id": api_key_id, + "Status": status, + }) diff --git a/backend/utils/billing_service.py b/backend/utils/billing_service.py index 20d3fcf..fc7731c 100644 --- a/backend/utils/billing_service.py +++ b/backend/utils/billing_service.py @@ -8,16 +8,21 @@ logger = logging.getLogger(__name__) class BillingService: - """封装火山引擎 Billing API""" + """封装火山引擎 Billing API + + 使用 ListSplitBillDetail(分账账单)而非 ListBillDetail(账单明细), + 因为后者的 Project 字段对 Seedance 等按量付费产品显示为 '-',不准确。 + 分账账单能正确按项目归属消费,与火山控制台分账账单页面一致。 + """ def __init__(self, ak: str, sk: str): self.client = get_billing_client(ak, sk) def get_spending_by_project(self, bill_period: str, project_name: str = None) -> Decimal: - """查询指定项目的消费总额(带分页)""" + """查询指定项目的消费总额(使用分账账单,带分页)""" total = Decimal("0") offset = 0 - page_size = 300 + page_size = 100 while True: params = { @@ -25,25 +30,52 @@ class BillingService: "Limit": str(page_size), "Offset": str(offset), "GroupTerm": "0", - "GroupPeriod": "0", - "NeedRecordNum": "1", + "GroupPeriod": "2", } - result = self.client.call("ListBillDetail", params) + result = self.client.call("ListSplitBillDetail", params) items = result.get("Result", {}).get("List", []) - record_num = int(result.get("Result", {}).get("Total", 0)) 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 amount = item.get("PayableAmount", "0") total += Decimal(str(amount)) - offset += page_size - if offset >= record_num or not items: + if len(items) < page_size: break + offset += page_size 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: """获取账单总览(按产品维度)""" result = self.client.call("ListBillOverviewByProd", { diff --git a/backend/utils/feishu.py b/backend/utils/feishu.py index ad11b0d..28adb4b 100644 --- a/backend/utils/feishu.py +++ b/backend/utils/feishu.py @@ -1,42 +1,171 @@ -"""飞书机器人通知""" +"""飞书自建应用通知(复用 AirDrama 的飞书应用,发私信卡片)""" +import json import logging import threading + import requests 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, template: str = "red"): - """发送飞书卡片消息(非阻塞)""" - if not webhook_url: - logger.warning(f"飞书 Webhook 未配置,跳过通知: {title}") - return + """发送飞书卡片消息到配置的接收人(非阻塞) + webhook_url 参数保留兼容性但不再使用,改为从 GlobalConfig 读取飞书应用配置。 + """ def _send(): - payload = { - "msg_type": "interactive", - "card": { - "config": {"wide_screen_mode": True}, - "header": { - "title": {"tag": "plain_text", "content": title}, - "template": template, + 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 + + if not mobiles_str: + logger.warning(f"飞书告警手机号未配置,跳过通知: {title}") + return + + mobiles = [m.strip() for m in mobiles_str.split(',') if m.strip()] + if not mobiles: + return + + 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", - "text": {"tag": "lark_md", "content": content}, + 'tag': 'div', + 'text': {'tag': 'lark_md', 'content': content}, } ], - }, - } - try: - resp = requests.post(webhook_url, json=payload, timeout=10) - resp.raise_for_status() - logger.info(f"飞书通知已发送: {title}") + } + + for mobile in mobiles: + try: + open_id = _get_open_id_by_mobile(token, mobile) + if not open_id: + 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: logger.error(f"飞书通知发送失败: {e}") thread = threading.Thread(target=_send, daemon=True) 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) diff --git a/backend/utils/iam_service.py b/backend/utils/iam_service.py index 7eca1c4..ad8ed7e 100644 --- a/backend/utils/iam_service.py +++ b/backend/utils/iam_service.py @@ -76,37 +76,151 @@ class IAMService: "PolicyType": policy_type, }) + def update_user(self, username: str, display_name: str = None, + email: str = None, phone: str = None) -> dict: + params = {"UserName": username} + if display_name is not None: + params["NewDisplayName"] = display_name + if email is not None: + params["NewEmail"] = email + if phone is not None: + params["NewMobilePhone"] = phone + return self.client.call("UpdateUser", params) + def list_attached_user_policies(self, username: str) -> dict: return self.client.call("ListAttachedUserPolicies", {"UserName": username}) def attach_policy_in_project(self, username: str, policy_name: str, project_name: str, policy_type: str = "System") -> dict: - """在项目范围内授权""" + """授权策略(全局),项目隔离靠 Deny 策略实现。 + 注意:火山 Open API 不支持项目级授权(Scope=Project 无效), + 所以统一走全局授权 + AirGate_Deny_{username} 策略隔离。""" return self.client.call("AttachUserPolicy", { "UserName": username, "PolicyName": policy_name, "PolicyType": policy_type, - "ProjectName": project_name, }) def detach_policy_in_project(self, username: str, policy_name: str, project_name: str, policy_type: str = "System") -> dict: - """在项目范围内回收权限""" + """回收策略(全局)""" return self.client.call("DetachUserPolicy", { "UserName": username, "PolicyName": policy_name, "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): """完全停用用户:停控制台 + 停所有 AccessKey""" errors = [] - try: - self.update_login_allowed(username, False) - except VolcengineAPIError as e: - errors.append(f"停用控制台失败: {e}") + if self._has_login_profile(username): + try: + self.update_login_allowed(username, False) + except VolcengineAPIError as e: + errors.append(f"停用控制台失败: {e}") try: keys = self.list_access_keys(username) @@ -119,14 +233,15 @@ class IAMService: if errors: raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors)) - def enable_user(self, username: str): - """恢复用户:恢复控制台 + 恢复所有 AccessKey""" + def enable_user(self, username: str, restore_login: bool = True): + """恢复用户:恢复控制台(可选) + 恢复所有 AccessKey""" errors = [] - try: - self.update_login_allowed(username, True) - except VolcengineAPIError as e: - errors.append(f"恢复控制台失败: {e}") + if restore_login and self._has_login_profile(username): + try: + self.update_login_allowed(username, True) + except VolcengineAPIError as e: + errors.append(f"恢复控制台失败: {e}") try: keys = self.list_access_keys(username) diff --git a/backend/utils/scheduler.py b/backend/utils/scheduler.py index 4ec0b62..5398ad7 100644 --- a/backend/utils/scheduler.py +++ b/backend/utils/scheduler.py @@ -31,6 +31,13 @@ def check_spending(): billing = BillingService(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( volc_account=volc_account, monitor_enabled=True, @@ -38,7 +45,7 @@ def check_spending(): for user in users: try: - # --- 遍历所有开启监测的项目,分别查询消费并累加 --- + # --- 遍历所有开启监测的项目,从批量结果中获取消费 --- enabled_projects = IAMUserProject.objects.filter( iam_user=user, monitor_enabled=True ) @@ -50,13 +57,9 @@ def check_spending(): total_spending = Decimal('0') for project in enabled_projects: - try: - proj_spending = billing.get_spending_by_project( - bill_period, project.project_name - ) - except Exception as e: - logger.error(f"查询项目 {project.project_name} 消费失败: {e}") - proj_spending = project.current_spending # 保留上次值 + proj_spending = all_project_spending.get( + project.project_name, project.current_spending + ) # 更新项目级消费 project.current_spending = proj_spending diff --git a/backend/utils/volcengine_client.py b/backend/utils/volcengine_client.py index fe52120..2a6e6ea 100644 --- a/backend/utils/volcengine_client.py +++ b/backend/utils/volcengine_client.py @@ -46,25 +46,25 @@ class VolcengineClient: def _hash_sha256(self, content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() - def call(self, action: str, params: dict = None, body: str = "") -> dict: - params = params or {} + def _sign_and_call(self, action: str, method: str, content_type: str, + query_params: dict, body_bytes: bytes) -> dict: + """统一签名并调用""" now = datetime.datetime.now(datetime.timezone.utc) x_date = now.strftime("%Y%m%dT%H%M%SZ") short_date = x_date[:8] - x_content_sha256 = self._hash_sha256(body) - all_params = {"Action": action, "Version": self.version, **params} + x_content_sha256 = hashlib.sha256(body_bytes).hexdigest() + query_string = self._norm_query(query_params) signed_headers_str = "content-type;host;x-content-sha256;x-date" canonical_headers = ( - f"content-type:application/x-www-form-urlencoded\n" + f"content-type:{content_type}\n" f"host:{self.host}\n" f"x-content-sha256:{x_content_sha256}\n" f"x-date:{x_date}" ) - query_string = self._norm_query(all_params) canonical_request = "\n".join([ - "GET", "/", query_string, + method, "/", query_string, canonical_headers, "", signed_headers_str, x_content_sha256 ]) @@ -84,7 +84,7 @@ class VolcengineClient: "Host": self.host, "X-Date": x_date, "X-Content-Sha256": x_content_sha256, - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": content_type, "Authorization": ( f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, " f"SignedHeaders={signed_headers_str}, Signature={signature}" @@ -93,7 +93,10 @@ class VolcengineClient: url = f"https://{self.host}/?{query_string}" try: - r = requests.get(url, headers=headers, timeout=30) + if method == "GET": + r = requests.get(url, headers=headers, timeout=30) + else: + r = requests.post(url, headers=headers, data=body_bytes, timeout=30) resp = r.json() except Exception as e: raise VolcengineAPIError(action, "NetworkError", str(e)) @@ -105,6 +108,21 @@ class VolcengineClient: ) return resp + def call(self, action: str, params: dict = None, body: str = "", extra_headers: dict = None) -> dict: + """GET 方式调用(IAM / Billing 等传统接口)""" + params = params or {} + all_params = {"Action": action, "Version": self.version, **params} + return self._sign_and_call(action, "GET", "application/x-www-form-urlencoded", + all_params, body.encode("utf-8") if body else b"") + + def call_json(self, action: str, body: dict = None) -> dict: + """POST + JSON body 方式调用(方舟 Ark 等新接口)""" + import json + query_params = {"Action": action, "Version": self.version} + body_bytes = json.dumps(body or {}).encode("utf-8") + return self._sign_and_call(action, "POST", "application/json", + query_params, body_bytes) + def get_iam_client(ak: str, sk: str) -> VolcengineClient: return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com") @@ -118,3 +136,9 @@ def get_billing_client(ak: str, sk: str) -> VolcengineClient: def get_resource_client(ak: str, sk: str) -> VolcengineClient: return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com", version="2021-08-01") + + +def get_ark_client(ak: str, sk: str) -> VolcengineClient: + """方舟 API 客户端(使用 POST + JSON body)""" + return VolcengineClient(ak, sk, "ark", "open.volcengineapi.com", + region="cn-beijing", version="2024-01-01") diff --git a/docker-compose.yml b/docker-compose.yml index 62ef58c..ce01bf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: - airgate-backend: + backend: build: ./backend ports: - "8101:8100" @@ -15,12 +15,12 @@ services: - backend-data:/app/data restart: unless-stopped - airgate-web: + frontend: build: ./frontend ports: - "5174:80" depends_on: - - airgate-backend + - backend restart: unless-stopped volumes: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 64b00e1..e56a400 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,7 +5,7 @@ server { index index.html; location /api/ { - proxy_pass http://airgate-backend:8100; + proxy_pass http://backend:8100; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 2d7d061..21a988f 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -4,37 +4,60 @@ - - - 仪表盘 - - - - 子账号管理 - - - - 消费监控 - - - - 告警记录 - - - - 系统设置 - - - - 系统管理 - + + + + + + - {{ auth.user?.username }} + 子账号 + + {{ auth.user?.display_name || auth.user?.username }} + 退出登录 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 8896347..2c83cfd 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -12,12 +12,24 @@ const routes = [ path: '/', component: () => import('../layouts/MainLayout.vue'), children: [ - { path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') }, + // Dynamic home: admin sees Dashboard, iam_user sees MyKeys + { + path: '', + name: 'Home', + component: () => import('../views/HomeRedirect.vue'), + }, + // Admin routes + { path: 'dashboard', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') }, { path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') }, + { path: '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: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') }, + { path: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') }, { path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') }, { path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') }, + // IAM user (sub-account) routes + { path: 'my-keys', name: 'MyKeys', component: () => import('../views/portal/MyKeysView.vue') }, + { path: 'my-password', name: 'MyPassword', component: () => import('../views/portal/MyPasswordView.vue') }, ], }, ] diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index fff08d1..3c8f32d 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -7,13 +7,15 @@ export const useAuthStore = defineStore('auth', () => { const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null')) const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role !== 'iam_user') + const isIamUser = computed(() => user.value?.role === 'iam_user') function setAuth(data) { token.value = data.access - refreshToken.value = data.refresh + refreshToken.value = data.refresh || '' user.value = data.user localStorage.setItem('airgate_token', data.access) - localStorage.setItem('airgate_refresh', data.refresh) + localStorage.setItem('airgate_refresh', data.refresh || '') localStorage.setItem('airgate_user', JSON.stringify(data.user)) } @@ -26,5 +28,5 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('airgate_user') } - return { token, refreshToken, user, isLoggedIn, setAuth, logout } + return { token, refreshToken, user, isLoggedIn, isAdmin, isIamUser, setAuth, logout } }) diff --git a/frontend/src/views/HomeRedirect.vue b/frontend/src/views/HomeRedirect.vue new file mode 100644 index 0000000..01acfd9 --- /dev/null +++ b/frontend/src/views/HomeRedirect.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 7a04860..88d1b79 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -5,9 +5,14 @@

AirGate

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

+ + + - + +
+
+

API Key 管理

+ 录入 API Key +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + 请先在火山控制台创建 API Key,然后将完整 Key 粘贴到下方录入。 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ revealData.key_name }} · {{ revealData.project_name }} +
+
+ {{ revealData.api_key }} +
+ +
+
+ + + diff --git a/frontend/src/views/iam/IAMUserList.vue b/frontend/src/views/iam/IAMUserList.vue index 0e888b9..d41aba1 100644 --- a/frontend/src/views/iam/IAMUserList.vue +++ b/frontend/src/views/iam/IAMUserList.vue @@ -51,11 +51,17 @@ 未划拨 - + @@ -76,10 +82,14 @@ + + + + + + + + + + + + + + + + +
+ 修改会同步到火山引擎 IAM +
+ +
+ { 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])], monitor_enabled: row.monitor_enabled, auto_disable_enabled: row.auto_disable_enabled, + deny_policy_exempt: row.deny_policy_exempt || false, } newStep.value = null 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() { if (!newStep.value || newStep.value < 1 || newStep.value > 99) { ElMessage.warning('请输入 1-99 之间的百分比') @@ -596,6 +537,42 @@ async function saveConfig() { } // --- Quota History --- +// === Set Login Password === +const loginPwdVisible = ref(false) +const loginPwdUser = ref(null) +const loginPwdValue = ref('') +const loginPwdEnabled = ref(false) +const loginPwdSaving = ref(false) + +function openSetLogin(row) { + loginPwdUser.value = row + loginPwdValue.value = '' + loginPwdEnabled.value = row.login_enabled || false + loginPwdVisible.value = true +} + +async function handleSetLogin() { + const payload = { login_enabled: loginPwdEnabled.value } + if (loginPwdValue.value) { + if (loginPwdValue.value.length < 6) { + ElMessage.warning('密码至少6位') + return + } + payload.password = loginPwdValue.value + } + loginPwdSaving.value = true + try { + const { data } = await api.post(`/api/v1/iam-users/${loginPwdUser.value.id}/set-login/`, payload) + ElMessage.success(data.message) + loginPwdVisible.value = false + await loadUsers() + } catch (e) { + ElMessage.error(e.response?.data?.message || '操作失败') + } finally { + loginPwdSaving.value = false + } +} + async function openQuotaHistory(row) { historyUser.value = row historyVisible.value = true @@ -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 --- async function handleCreate() { if (!createForm.value.username) { diff --git a/frontend/src/views/iam/UserPoliciesView.vue b/frontend/src/views/iam/UserPoliciesView.vue new file mode 100644 index 0000000..b63aeb6 --- /dev/null +++ b/frontend/src/views/iam/UserPoliciesView.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/frontend/src/views/portal/MyKeysView.vue b/frontend/src/views/portal/MyKeysView.vue new file mode 100644 index 0000000..db6f616 --- /dev/null +++ b/frontend/src/views/portal/MyKeysView.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/portal/MyPasswordView.vue b/frontend/src/views/portal/MyPasswordView.vue new file mode 100644 index 0000000..b317e27 --- /dev/null +++ b/frontend/src/views/portal/MyPasswordView.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 1767fa7..dd47f37 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -23,14 +23,25 @@ - - + 飞书通知 + + - - + + + + + +
+ 填写飞书用户的手机号,告警会以私信卡片发送 +
保存配置 + + 测试飞书通知 +
@@ -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() { loadingAccounts.value = true try { diff --git a/操作说明.md b/操作说明.md index 6be3fd2..17fb6b4 100644 --- a/操作说明.md +++ b/操作说明.md @@ -17,7 +17,7 @@ cd C:\Airlabs_Project\AirGate\frontend npm run dev ``` -打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录。 +打开 `http://localhost:5174`,使用 `admin` / `admin123` 登录(首次登录后请立即修改密码)。 ### 2. 配置火山主账号 @@ -35,132 +35,173 @@ npm run dev | 配置项 | 说明 | 建议值 | |--------|------|--------| | 默认告警阶梯(%) | 消费达到额度的百分比时告警 | 50,80,90 | -| 项目默认授权策略 | 添加项目时自动授权的策略 | ArkFullAccess,TOSFullAccess | | 监控间隔(秒) | 定时查询消费的间隔 | 3600(1小时) | | 飞书 Webhook URL | 告警通知地址 | 从飞书群机器人获取 | --- -## 二、日常操作 +## 二、管理员操作 -### 给新部门开通子账号 - -**步骤 1:创建子账号** +### 1. 创建子账号 1. 左侧菜单 → **子账号管理** → 点 **创建子账号** 2. 填写: - **用户名**:英文,如 `dept_video` - **显示名**:如 `视频部门` - - **火山控制台密码**:填上(对方需要登录火山后台创建方舟 API Key) - - 其他选填 + - **手机号**:可选 + - **火山控制台密码**:需包含大小写字母、数字和特殊字符,8位以上(火山密码策略要求) 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 → 子账号管理 → 找到刚创建的子账号 -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 - ---- - -### 给子账号追加/扣减额度 +### 3. 额度划拨 1. 子账号管理 → 找到目标用户 → 点 **划拨** -2. 选择「追加额度」或「扣减额度」 -3. 输入金额和备注 → 确认 +2. 输入正数追加、负数扣减 +3. **必须填写备注** → 确认 > 扣减有保护:总额度不能低于已消费金额 ---- +### 4. 监控配置 -### 给子账号增加新项目 +1. 子账号管理 → 找到目标用户 → 点 **监控配置** +2. 可配置项: + - **阶梯告警**:自定义告警百分比阶梯(如 50,80,90),未设置则使用全局默认值 + - **消费监控开关**:开启/关闭该用户的消费监控 + - **自动停用开关**:额度用尽时是否自动停用(关闭则只告警不停用) + - **Deny策略免除开关**:管理员自用账号可开启,免除 Deny 策略限制 -1. 先在火山控制台创建新项目 -2. 回到 AirGate → 子账号管理 → 更多 → **项目管理** -3. 从下拉框选择新项目 → 添加(自动授权) - ---- - -### 关闭某个项目的监测 - -1. 子账号管理 → 更多 → **项目管理** -2. 找到目标项目 → 关闭「监测」开关 -3. 该项目的消费不再计入子账号的累计消费(不影响告警和停用判断) - ---- - -### 手动停用/恢复子账号 +### 5. 停用/恢复账号 **停用:** 1. 子账号管理 → 更多 → **停用账号** -2. 确认后,子账号的控制台登录和所有 API Key 立即失效 +2. 系统自动执行: + - 关闭火山控制台登录 + - 停用所有 API Key + - 移除所有权限策略 + - 保存策略快照(区分全局策略/项目级策略 + 登录状态),用于恢复 **恢复:** 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 认证供外部系统调用: @@ -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/ ``` -完整 API 列表见 [README.md](README.md) 或研究报告第 11 章。 +完整 API 列表见研究报告第 11 章。 --- -## 五、注意事项 +## 七、火山API限制 -1. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量 -2. **SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了,务必保存 -3. **项目由你创建**:子账号没有创建项目的权限,需要新项目时在火山控制台创建后在 AirGate 关联 -4. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭 -5. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的火山主账号 AK/SK 无法解密,需要重新配置 +1. **项目级策略(Scope=Project)通过API无法设置**:需在火山控制台手动操作,AirGate 的项目隔离完全依赖 Deny 策略 +2. **火山控制台密码要求**:需包含大小写字母、数字和特殊字符,8位以上 +3. **消费数据有 1-2 天延迟**:火山 Billing API 的限制,划拨额度时建议预留余量 +4. **IAM SecretKey 只显示一次**:创建子账号时弹窗里的 SecretAccessKey 关掉就没了 +5. **方舟 API Key 由管理员录入**:火山 API 不返回 Key 明文,需要在火山控制台创建后手动录入 AirGate +6. **子账号不登录火山控制台**:所有操作通过 AirGate 完成,避免权限泄露 +7. **项目由管理员创建**:子账号没有创建项目的权限,需要新项目时联系管理员 +8. **seaislee 账号不要动**:这是你自己的子账号,监控和自动停用已关闭 +9. **加密密钥不要丢**:`.env` 中的 `AIRGATE_ENCRYPTION_KEY` 丢失后,已存储的密钥无法解密 diff --git a/火山引擎IAM子账号管控工具_深度研究报告.md b/火山引擎IAM子账号管控工具_深度研究报告.md index b9a0586..7402ae0 100644 --- a/火山引擎IAM子账号管控工具_深度研究报告.md +++ b/火山引擎IAM子账号管控工具_深度研究报告.md @@ -1,7 +1,7 @@ # 火山引擎 IAM 子账号管控工具 -- 深度研究报告 -> 研究日期:2026-03-19 -> 目标:通过火山引擎 Open API,实现对 IAM 子账号的全面管控,包括权限隔离、消费监控、告警、自动停用等功能。 +> 研究日期:2026-03-19(最后更新:2026-03-20) +> 目标:通过火山引擎 Open API,构建 IAM 子账号的完整管控平台。子账号**不登录火山控制台**,所有操作(API Key 管理、消费查询等)均通过 AirGate 完成,实现权限隔离、消费监控、告警、自动停用等功能。 --- @@ -18,8 +18,10 @@ 9. [项目管理与资源隔离](#9-项目管理与资源隔离) 10. [SDK 与工具链](#10-sdk-与工具链) 11. [可执行实施方案](#11-可执行实施方案) -12. [限制与注意事项](#12-限制与注意事项) -13. [参考文档](#13-参考文档) +12. [方舟 API Key 管理](#12-方舟-api-key-管理) +13. [实测发现与架构决策](#13-实测发现与架构决策) +14. [限制与注意事项](#14-限制与注意事项) +15. [参考文档](#15-参考文档) --- @@ -29,40 +31,54 @@ | 需求 | 实现方式 | 可行性 | |------|----------|--------| -| 子账号不能看到主账号信息 | IAM 默认零权限 + 显式 Deny 策略 | **完全可行** | -| 子账号仅有 Seedance 2.0 + TOS 权限 | 仅附加 ArkFullAccess + TOSFullAccess 策略 | **完全可行** | +| 子账号不能看到主账号信息 | **子账号不登录火山控制台**,只登录 AirGate | **完全可行** | +| 子账号仅有 Seedance 2.0 + TOS 权限 | 项目级附加 ArkFullAccess + TOSFullAccess,全局无权限 | **完全可行** | | 子账号能看到自己的账单 | 通过 AirGate 按多项目聚合查询,主账号代查展示,可按项目查看明细 | **完全可行** | -| 子账号不能看到其他账号消费/余额 | 不授予 billing/bss 权限 + 显式 Deny | **完全可行** | +| 子账号不能看到其他账号消费/余额 | AirGate 只展示自己的数据,子账号进不了火山后台 | **完全可行** | +| 子账号能查看自己的 API Key | 管理员在火山控制台创建 Key 后录入 AirGate,子账号登录 AirGate 查看 | **完全可行** | | 消费达到阈值发告警 | 额度划拨制 + 阶梯式告警(50%/80%/90%)+ 飞书通知 | **完全可行** | -| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用 | **完全可行** | -| 一键恢复子账号 | 调用 IAM API 重新启用 | **完全可行** | +| 消费达到阈值自动停用 | 消费达到已划拨额度 100% 时自动停用(停登录+停密钥+移除策略) | **完全可行** | +| 一键恢复子账号 | 调用 IAM API 恢复登录+密钥+策略(从快照恢复) | **完全可行** | ### 1.2 架构图 ``` -┌──────────────────────────────────────────────────────┐ -│ 管控工具 (后端服务) │ -│ │ -│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ -│ │ IAM管理 │ │ 消费监控 │ │ 告警引擎 │ │ -│ │ 模块 │ │ 模块 │ │ 模块 │ │ -│ └────┬─────┘ └────┬─────┘ └────┬───────┘ │ -│ │ │ │ │ -│ ┌────▼─────────────▼─────────────▼───────┐ │ -│ │ 火山引擎 Open API 调用层 │ │ -│ │ (HMAC-SHA256 签名认证) │ │ -│ └────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────┐ +│ AirGate(子账号的唯一操作入口) │ +│ │ +│ 管理员界面 子账号界面 │ +│ ┌───────────────┐ ┌──────────────┐ │ +│ │ 子账号管理 │ │ 我的 API Key │ │ +│ │ 消费监控/告警 │ │ 我的消费 │ │ +│ │ 额度划拨 │ │ 我的项目 │ │ +│ │ 权限配置 │ └──────────────┘ │ +│ │ 系统管理 │ │ +│ └───────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 火山引擎 Open API 调用层 │ │ +│ │ IAM API | Billing API | 方舟 API (Ark) │ │ +│ │ (HMAC-SHA256 签名认证) │ │ +│ └────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ │ │ │ - ┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐ - │ IAM API │ │Billing API│ │CloudMonitor│ + ┌────▼────┐ ┌─────▼─────┐ ┌────▼──────┐ + │ IAM API │ │Billing API│ │ 方舟 API │ │iam.vol..│ │billing.vol│ │open.vol.. │ - └─────────┘ └───────────┘ └────────────┘ + └─────────┘ └───────────┘ └───────────┘ ``` -### 1.3 关键发现 +### 1.3 关键发现与架构决策 -> **重要**:火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**或**标签(Tag)**维度来实现,由主账号通过 Billing API 查询后聚合展示。 +> **重要发现(2026-03-20 实测验证)**: +> +> 1. **火山控制台的权限隔离不彻底**:给子账号全局 `ArkReadOnlyAccess` 后,子账号在方舟控制台能看到**所有项目**的 API Key,包括其他子账号和主账号的资源。项目级授权能控制"能不能操作",但控制台页面渲染依赖全局只读权限。 +> +> 2. **方舟 API Key 管理页面需要 `ArkExperienceAccess` 全局权限**:即使给了项目级 `ArkFullAccess`,不加全局体验权限就无法进入 API Key 管理页面。而加了全局权限又会泄露其他项目信息。 +> +> 3. **架构决策**:基于以上发现,AirGate 的定位从"管控工具"升级为"子账号的唯一操作入口"。子账号**不登录火山控制台**,所有操作(创建/查看/删除 API Key、查看消费等)均通过 AirGate 完成。AirGate 使用主账号的 AK/SK 调用火山 API,在应用层做项目级隔离。 +> +> 4. 火山引擎的 IAM 子用户**没有独立的计费账户**。所有费用归属主账号。子账号的消费追踪需要通过**项目(Project)**维度来实现。 --- @@ -530,7 +546,22 @@ iam_client.call("UpdateAccessKey", { - 管理员可按需开关某些项目的监测(如测试项目不计费) - 告警和自动停用基于所有开启项目的消费总和 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 账户余额查询 @@ -868,13 +899,14 @@ iam.call("CreateUser", { "MobilePhone": "+8618000000000" }) -# 开通控制台登录 -iam.call("CreateLoginProfile", { - "UserName": "dept_a_user", - "Password": "Initial@Pass123", - "LoginAllowed": "true", - "PasswordResetRequired": "true" -}) +# 注意:不开通控制台登录(子账号通过 AirGate 操作,不登录火山控制台) +# 如果确实需要开通控制台登录(不推荐),取消以下注释: +# iam.call("CreateLoginProfile", { +# "UserName": "dept_a_user", +# "Password": "Initial@Pass123", +# "LoginAllowed": "true", +# "PasswordResetRequired": "true" +# }) # 创建 API 密钥(记录返回的 SecretAccessKey!) result = iam.call("CreateAccessKey", {"UserName": "dept_a_user"}) @@ -1119,25 +1151,223 @@ GET /api/v1/alerts/ # 告警历史(支持类型筛 # 项目列表 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 Key(AES-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=Project(2026-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 个项目,消费=开启监测的项目之和) | | Billing API 无实时数据 | 最快 T+1 天粒度,有 1-2 天延迟 | -| 每用户最多 2 个 API 密钥 | 无法创建更多 | -| SecretKey 仅返回一次 | 创建后立即保存 | +| 每用户最多 2 个 IAM AccessKey | IAM 级别的 AK/SK 最多 2 对(方舟 API Key 无此限制) | +| IAM SecretKey 仅返回一次 | 创建后立即保存 | | Billing API QPS 限制 5 | 批量查询需注意限流 | -| Ark 推理限额无公开 API | 目前仅支持控制台操作 | +| **火山控制台无法做项目级视图隔离** | 全局只读权限会暴露所有项目的资源(实测验证),所以子账号不登录火山控制台 | +| **方舟 API Key 管理需全局权限** | 控制台 API Key 页面需要 `ArkExperienceAccess` 全局权限,无法限定项目范围 | +| **方舟 CreateApiKey 不返回 Key 明文** | 只返回 ID,ListApiKeys 返回脱敏值,明文只在控制台创建时显示一次。AirGate 采用管理员手动录入方案 | +| 停用账号不会踢掉已登录会话 | 需要同时移除策略,子账号刷新页面后才失效 | | 火山原生预算告警仅通知不自动执行 | AirGate 已自建额度划拨+阶梯告警+自动停用 | +| 方舟 API 使用 POST + JSON body | 与 IAM/Billing 的 GET + Query 方式不同,签名方式也不同 | -### 12.2 安全建议 +### 14.2 安全建议 1. **主账号 AK/SK 务必安全存储**,建议使用环境变量或密钥管理服务 2. **定期轮换 API 密钥**,利用 `GetAccessKeyLastUsed` 检查不活跃的密钥 @@ -1145,7 +1375,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表 4. **显式 Deny 策略优先**,防止权限漏洞 5. **监控日志**,使用 CloudTrail 审计 API 调用 -### 12.3 消费监控的精确度问题 +### 14.3 消费监控的精确度问题 由于账单数据有 1-2 天延迟,消费监控存在滞后。AirGate 的应对策略: - **额度划拨制**:划拨的额度应预留 1-2 天延迟的消费余量(如实际想控制 10 万,可划拨 9 万并设阈值 [50, 80, 90]) @@ -1155,7 +1385,7 @@ GET /api/v1/projects/ # 从火山拉取项目列表 --- -## 13. 参考文档 +## 15. 参考文档 ### 官方文档 diff --git a/版本管理.md b/版本管理.md index 9cff794..c22e74e 100644 --- a/版本管理.md +++ b/版本管理.md @@ -2,6 +2,26 @@ --- +## v0.5.0 (2026-03-28 ~ 2026-03-29) + +### 权限管理重构 +- feat: 统一权限管理页面(全局策略 + 项目级策略 + 关联项目 + 添加/移除策略,一页展示) +- feat: Deny策略自动化(项目隔离)—— 创建子账号时自动生成 `AirGate_Deny_{username}`,添加/移除项目时自动更新 +- feat: Deny策略免除开关(管理员自用账号可在监控配置中开启) +- fix: 火山API不支持Scope=Project(2026-03-28实测),改用全局授权 + Deny策略实现项目隔离 +- fix: 项目变动刷新所有用户Deny策略 +- fix: 前后端权限显示一致(实时从火山查询,不再依赖本地缓存) + +### 账号管理增强 +- feat: 火山控制台登录开关(独立于停用/恢复,随时切换) +- feat: 编辑子账号信息(显示名、手机号、邮箱,修改后同步火山 `UpdateUser` API) +- feat: 创建子账号密码校验(火山密码策略:大小写字母 + 数字 + 特殊字符,8位以上) +- fix: 停用/恢复保存策略快照(区分全局策略/项目级策略 + 登录状态,恢复时精确还原) +- fix: 同步不再把火山登录关闭当成账号停用 +- fix: 检测幽灵LoginProfile(CreateDate=1970,火山API已知问题) + +--- + ## v0.4.0 (2026-03-20) ### UI 优化