seaislee1209 5edf247a7f feat: auto-authorize policies when adding projects to sub-accounts
Project-level authorization:
- Adding a project to a sub-account now auto-calls AttachPolicyInProject
  to grant default policies (ArkFullAccess, TOSFullAccess) in that project scope
- Removing a project auto-calls DetachPolicyInProject to revoke those policies
- Each project records which policies were attached (attached_policies field)
  so removal knows exactly what to revoke

Configuration:
- GlobalConfig.default_project_policies: configurable list of policies to
  auto-attach (editable in Settings page, defaults to ArkFullAccess + TOSFullAccess)

IAM Service:
- Added attach_policy_in_project() and detach_policy_in_project() methods
  using standard AttachUserPolicy/DetachUserPolicy with ProjectName parameter

Frontend:
- Projects dialog now shows "已授权策略" column with policy tags
- Settings page has "项目默认授权策略" config field

Alert logging:
- Project add/remove operations are logged with attached/detached policy details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:24:45 +08:00

205 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from decimal import Decimal
from django.db import models
class VolcAccount(models.Model):
"""火山引擎主账号配置(加密存储)"""
name = models.CharField('账号名称', max_length=100, default='默认主账号')
access_key_enc = models.TextField('AccessKey加密', blank=True)
secret_key_enc = models.TextField('SecretKey加密', blank=True)
access_key_hint = models.CharField('AK 提示前4后4', max_length=20, blank=True)
is_active = models.BooleanField('启用', default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '火山主账号'
verbose_name_plural = '火山主账号'
db_table = 'airgate_volc_account'
def __str__(self):
return f"{self.name} ({self.access_key_hint})"
class IAMUser(models.Model):
"""受管理的 IAM 子账号"""
class Status(models.TextChoices):
ACTIVE = 'active', '正常'
DISABLED = 'disabled', '已停用'
UNKNOWN = 'unknown', '未知'
volc_account = models.ForeignKey(VolcAccount, on_delete=models.CASCADE, related_name='iam_users')
username = models.CharField('IAM 用户名', max_length=200, db_index=True)
display_name = models.CharField('显示名', max_length=200, blank=True)
user_id = models.CharField('火山 UserID', max_length=100, blank=True)
email = models.EmailField('邮箱', blank=True)
phone = models.CharField('手机号', max_length=20, blank=True)
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
# Access keys (stored as JSON list of AK IDs, not secrets)
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
# --- 额度管理(划拨制) ---
allocated_quota = models.DecimalField('已划拨额度(元)', max_digits=12, decimal_places=2, default=0,
help_text='主账号累计划拨给此子账号的总额度')
consumed_total = models.DecimalField('累计消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='从 Billing API 获取的累计消费总额')
spending_updated_at = models.DateTimeField('消费更新时间', null=True, blank=True)
# --- 监控配置 ---
monitor_enabled = models.BooleanField('启用消费监控', default=True)
alert_thresholds = models.JSONField('告警阈值(百分比列表)', default=list, blank=True,
help_text='如 [50, 80, 90] 表示消费达到额度的 50%/80%/90% 时告警')
auto_disable_enabled = models.BooleanField('额度用尽自动停用', default=True,
help_text='消费达到已划拨额度 100% 时自动停用')
triggered_alerts = models.JSONField('已触发的告警阈值', default=list, blank=True,
help_text='记录已通知过的百分比,划拨新额度时自动重置')
remark = models.TextField('备注', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'IAM 子账号'
verbose_name_plural = 'IAM 子账号'
db_table = 'airgate_iam_user'
unique_together = [('volc_account', 'username')]
def __str__(self):
return f"{self.display_name or self.username} ({self.status})"
@property
def remaining_quota(self):
"""剩余额度"""
return max(Decimal('0'), self.allocated_quota - self.consumed_total)
@property
def usage_percent(self):
"""额度使用率"""
if self.allocated_quota <= 0:
return 0
return round(float(self.consumed_total) / float(self.allocated_quota) * 100, 1)
def get_alert_thresholds(self):
if self.alert_thresholds:
return sorted(self.alert_thresholds)
config = GlobalConfig.get_solo()
return sorted(config.default_alert_thresholds or [50, 80, 90])
class IAMUserProject(models.Model):
"""子账号关联的火山项目(多对多)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='projects')
project_name = models.CharField('火山项目名', max_length=200)
display_name = models.CharField('显示名', max_length=200, blank=True)
monitor_enabled = models.BooleanField('启用监测', default=True)
current_spending = models.DecimalField('当前消费(元)', max_digits=12, decimal_places=2, default=0,
help_text='此项目的累计消费,由定时任务更新')
attached_policies = models.JSONField('已授权的策略列表', default=list, blank=True,
help_text='添加项目时自动附加的策略名列表,移除时自动回收')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '子账号关联项目'
verbose_name_plural = '子账号关联项目'
db_table = 'airgate_iam_user_project'
unique_together = [('iam_user', 'project_name')]
ordering = ['project_name']
def __str__(self):
status = '监测中' if self.monitor_enabled else '未监测'
return f"{self.project_name} ({status}) ¥{self.current_spending}"
class QuotaAllocation(models.Model):
"""额度划拨记录"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='quota_allocations')
amount = models.DecimalField('变更金额(元,正=追加,负=扣减)', max_digits=12, decimal_places=2)
total_after = models.DecimalField('划拨后总额度', max_digits=12, decimal_places=2)
note = models.CharField('备注', max_length=500, blank=True)
created_by = models.CharField('操作人', max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '额度划拨记录'
verbose_name_plural = '额度划拨记录'
db_table = 'airgate_quota_allocation'
ordering = ['-created_at']
def __str__(self):
return f"{self.iam_user.username}{self.amount} → ¥{self.total_after}"
class GlobalConfig(models.Model):
"""全局配置(单例)"""
default_alert_thresholds = models.JSONField('默认告警阈值(百分比列表)', default=list, blank=True,
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)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '全局配置'
verbose_name_plural = '全局配置'
db_table = 'airgate_global_config'
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return '全局配置'
class AlertRecord(models.Model):
"""告警记录"""
class AlertType(models.TextChoices):
WARNING = 'warning', '告警'
DISABLE = 'disable', '自动停用'
ERROR = 'error', '错误'
MANUAL = 'manual', '手动操作'
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='alerts', null=True, blank=True)
alert_type = models.CharField('告警类型', max_length=20, choices=AlertType.choices)
title = models.CharField('标题', max_length=200)
content = models.TextField('详情')
spending_amount = models.DecimalField('触发时消费金额', max_digits=12, decimal_places=2, null=True, blank=True)
threshold_amount = models.DecimalField('触发阈值', max_digits=12, decimal_places=2, null=True, blank=True)
notified = models.BooleanField('已通知', default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = '告警记录'
verbose_name_plural = '告警记录'
db_table = 'airgate_alert_record'
ordering = ['-created_at']
def __str__(self):
return f"[{self.alert_type}] {self.title}"
class SpendingRecord(models.Model):
"""月度消费快照(按项目粒度)"""
iam_user = models.ForeignKey(IAMUser, on_delete=models.CASCADE, related_name='spending_records')
project_name = models.CharField('项目名', max_length=200, blank=True,
help_text='空=子账号总消费')
bill_period = models.CharField('账期 (YYYY-MM)', max_length=7, db_index=True)
amount = models.DecimalField('消费金额(元)', max_digits=12, decimal_places=2, default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '消费记录'
verbose_name_plural = '消费记录'
db_table = 'airgate_spending_record'
unique_together = [('iam_user', 'project_name', 'bill_period')]
def __str__(self):
proj = self.project_name or '总计'
return f"{self.iam_user.username} [{proj}] {self.bill_period}: ¥{self.amount}"