Backend service for reading/writing profile .env files with masking, auto-backup, batch overwrite, batch scan, diff view, and CSV export. Owner/operator-only API endpoints. Frontend KeysPage with batch-overwrite modal, batch-scan results, diff view with highlight, and CSV download. Co-Authored-By: Claude <noreply@anthropic.com>
107 lines
3.4 KiB
Python
107 lines
3.4 KiB
Python
"""Keys API — 密钥批量管理。"""
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.responses import PlainTextResponse
|
|
|
|
from app.database import get_db
|
|
from app.middleware.auth import require_role
|
|
from app.models.audit import AuditLog
|
|
from app.models.user import User
|
|
from app.services.key_manager import (
|
|
WHITELIST_FIELDS,
|
|
get_keys,
|
|
batch_overwrite,
|
|
batch_scan as scan_profiles,
|
|
get_diff,
|
|
export_csv,
|
|
list_profiles,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/keys", tags=["keys"])
|
|
|
|
|
|
@router.get("")
|
|
async def list_keys(
|
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
|
_user: User = Depends(require_role("owner", "operator")),
|
|
):
|
|
"""返回密钥字段列表(脱敏显示)。"""
|
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
|
|
data = get_keys(profile_list)
|
|
return {"data": data}
|
|
|
|
|
|
@router.post("/batch-overwrite")
|
|
async def batch_overwrite_keys(
|
|
body: dict,
|
|
user: User = Depends(require_role("owner", "operator")),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""批量覆盖密钥。"""
|
|
profile_names = body.get("profile_names", [])
|
|
key_name = body.get("key_name", "")
|
|
new_value = body.get("new_value", "")
|
|
confirmation = body.get("confirmation", "")
|
|
|
|
if key_name not in WHITELIST_FIELDS:
|
|
raise HTTPException(status_code=400, detail=f"不支持的密钥字段: {key_name}")
|
|
|
|
# 验证确认短语
|
|
expected = f"我确认覆盖 {len(profile_names)} 个 Profile"
|
|
if confirmation != expected:
|
|
raise HTTPException(status_code=400, detail="确认短语不匹配")
|
|
|
|
results = batch_overwrite(profile_names, key_name, new_value)
|
|
|
|
# 审计日志(不记录原文)
|
|
audit = AuditLog(
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="keys:batch-overwrite",
|
|
target=f"profiles:{','.join(profile_names)}",
|
|
detail=f"key={key_name}, profiles={','.join(profile_names)}, "
|
|
f"success={sum(1 for r in results if r['success'])}/{len(results)}",
|
|
)
|
|
db.add(audit)
|
|
await db.commit()
|
|
|
|
return {"results": results}
|
|
|
|
|
|
@router.post("/batch-scan")
|
|
async def batch_scan_keys(
|
|
body: dict,
|
|
_user: User = Depends(require_role("owner", "operator")),
|
|
):
|
|
"""批量检查缺失密钥。"""
|
|
profile_names = body.get("profile_names")
|
|
if profile_names is not None and not isinstance(profile_names, list):
|
|
raise HTTPException(status_code=400, detail="profile_names 必须为数组")
|
|
results = scan_profiles(profile_names)
|
|
return {"results": results}
|
|
|
|
|
|
@router.get("/diff")
|
|
async def diff_keys(
|
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
|
_user: User = Depends(require_role("owner", "operator")),
|
|
):
|
|
"""差异视图。"""
|
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else list_profiles()
|
|
rows = get_diff(profile_list)
|
|
return {"profiles": profile_list, "rows": rows}
|
|
|
|
|
|
@router.get("/export", response_class=PlainTextResponse)
|
|
async def export_keys_csv(
|
|
profiles: str = Query("", description="逗号分隔的 Profile 名"),
|
|
_user: User = Depends(require_role("owner", "operator")),
|
|
):
|
|
"""CSV 导出批量检查结果。"""
|
|
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
|
|
return export_csv(profile_list)
|