Quota allocation system: - Replace monthly budget with one-time quota allocation (prepaid model) - Support both adding (+) and deducting (-) quota with underflow protection - Stepped alerts at configurable percentages (e.g., 50%/80%/90%) - Auto-disable when quota exhausted (100%), alert state resets on new allocation - Quota allocation history with operator audit trail IAM management: - Create new IAM sub-accounts directly from AirGate (auto-generates API keys) - SecretKey shown once in dialog with copy-to-clipboard - Attach/detach IAM policies via UI (ArkFullAccess, TOSFullAccess, etc.) - Sync existing users from Volcengine - Project list pulled from Volcengine API for dropdown selection Security & auth: - API Key authentication for external systems (AirDrama integration) - SECRET_KEY enforced in production (raises error if missing with DEBUG=False) - APIKeyUser with proper pk/is_staff attributes for DRF compatibility Infrastructure: - Docker + docker-compose for backend and frontend - Nginx reverse proxy for frontend with /api/ forwarding - Entrypoint with auto-migrate and default admin creation - SQLite data persisted via Docker volume at /app/data/ Bug fixes from audit: - Fix frontend referencing non-existent fields (current_month_spending, effective_budget, budget_usage_percent) - Fix scheduler using naive datetime.now() → timezone.now() - Fix scheduler reading interval from settings instead of GlobalConfig DB - Fix docker-compose SQLite volume mounting as directory - Fix CORS origin with explicit port 80 - Remove dead config (VOLC_ACCESS_KEY/SK, MONITOR_INTERVAL from settings) - Remove unused imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
"""IAM 子账号管理服务"""
|
|
|
|
import logging
|
|
from .volcengine_client import get_iam_client, get_resource_client, VolcengineAPIError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class IAMService:
|
|
"""封装火山引擎 IAM API 操作"""
|
|
|
|
def __init__(self, ak: str, sk: str):
|
|
self.client = get_iam_client(ak, sk)
|
|
|
|
def list_users(self, limit=100, offset=0) -> dict:
|
|
return self.client.call("ListUsers", {"Limit": str(limit), "Offset": str(offset)})
|
|
|
|
def get_user(self, username: str) -> dict:
|
|
return self.client.call("GetUser", {"UserName": username})
|
|
|
|
def create_user(self, username: str, display_name: str = "", email: str = "",
|
|
phone: str = "") -> dict:
|
|
params = {"UserName": username}
|
|
if display_name:
|
|
params["DisplayName"] = display_name
|
|
if email:
|
|
params["Email"] = email
|
|
if phone:
|
|
params["MobilePhone"] = phone
|
|
return self.client.call("CreateUser", params)
|
|
|
|
def create_login_profile(self, username: str, password: str,
|
|
login_allowed: bool = True, must_reset: bool = True) -> dict:
|
|
return self.client.call("CreateLoginProfile", {
|
|
"UserName": username,
|
|
"Password": password,
|
|
"LoginAllowed": str(login_allowed).lower(),
|
|
"PasswordResetRequired": str(must_reset).lower(),
|
|
})
|
|
|
|
def update_login_allowed(self, username: str, allowed: bool) -> dict:
|
|
return self.client.call("UpdateLoginProfile", {
|
|
"UserName": username,
|
|
"LoginAllowed": str(allowed).lower(),
|
|
})
|
|
|
|
def get_login_profile(self, username: str) -> dict:
|
|
return self.client.call("GetLoginProfile", {"UserName": username})
|
|
|
|
def list_access_keys(self, username: str) -> list:
|
|
resp = self.client.call("ListAccessKeys", {"UserName": username})
|
|
return resp.get("Result", {}).get("AccessKeyMetadata", [])
|
|
|
|
def update_access_key(self, ak_id: str, status: str, username: str = "") -> dict:
|
|
params = {"AccessKeyId": ak_id, "Status": status}
|
|
if username:
|
|
params["UserName"] = username
|
|
return self.client.call("UpdateAccessKey", params)
|
|
|
|
def create_access_key(self, username: str) -> dict:
|
|
return self.client.call("CreateAccessKey", {"UserName": username})
|
|
|
|
def attach_user_policy(self, username: str, policy_name: str,
|
|
policy_type: str = "System") -> dict:
|
|
return self.client.call("AttachUserPolicy", {
|
|
"UserName": username,
|
|
"PolicyName": policy_name,
|
|
"PolicyType": policy_type,
|
|
})
|
|
|
|
def detach_user_policy(self, username: str, policy_name: str,
|
|
policy_type: str = "System") -> dict:
|
|
return self.client.call("DetachUserPolicy", {
|
|
"UserName": username,
|
|
"PolicyName": policy_name,
|
|
"PolicyType": policy_type,
|
|
})
|
|
|
|
def list_attached_user_policies(self, username: str) -> dict:
|
|
return self.client.call("ListAttachedUserPolicies", {"UserName": username})
|
|
|
|
def disable_user(self, username: str):
|
|
"""完全停用用户:停控制台 + 停所有 AccessKey"""
|
|
errors = []
|
|
|
|
try:
|
|
self.update_login_allowed(username, False)
|
|
except VolcengineAPIError as e:
|
|
errors.append(f"停用控制台失败: {e}")
|
|
|
|
try:
|
|
keys = self.list_access_keys(username)
|
|
for key in keys:
|
|
if key.get("Status") == "active":
|
|
self.update_access_key(key["AccessKeyId"], "inactive", username)
|
|
except VolcengineAPIError as e:
|
|
errors.append(f"停用密钥失败: {e}")
|
|
|
|
if errors:
|
|
raise VolcengineAPIError("DisableUser", "PartialFailure", "; ".join(errors))
|
|
|
|
def enable_user(self, username: str):
|
|
"""恢复用户:恢复控制台 + 恢复所有 AccessKey"""
|
|
errors = []
|
|
|
|
try:
|
|
self.update_login_allowed(username, True)
|
|
except VolcengineAPIError as e:
|
|
errors.append(f"恢复控制台失败: {e}")
|
|
|
|
try:
|
|
keys = self.list_access_keys(username)
|
|
for key in keys:
|
|
if key.get("Status") == "inactive":
|
|
self.update_access_key(key["AccessKeyId"], "active", username)
|
|
except VolcengineAPIError as e:
|
|
errors.append(f"恢复密钥失败: {e}")
|
|
|
|
if errors:
|
|
raise VolcengineAPIError("EnableUser", "PartialFailure", "; ".join(errors))
|
|
|
|
|
|
class ProjectService:
|
|
"""封装火山引擎项目管理 API"""
|
|
|
|
def __init__(self, ak: str, sk: str):
|
|
self.client = get_resource_client(ak, sk)
|
|
|
|
def list_projects(self) -> list:
|
|
"""获取所有项目列表"""
|
|
projects = []
|
|
page = 1
|
|
while True:
|
|
resp = self.client.call("ListProjects", {
|
|
"PageNumber": str(page),
|
|
"PageSize": "50",
|
|
})
|
|
items = resp.get("Result", {}).get("Projects", [])
|
|
if not items:
|
|
break
|
|
projects.extend(items)
|
|
total = resp.get("Result", {}).get("Total", 0)
|
|
if len(projects) >= total:
|
|
break
|
|
page += 1
|
|
return projects
|