feat: Key batch management (Key Manager service, API, frontend) (Phase 6)
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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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)
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.api import auth, users, audit, dashboard, gateway, health, cron
|
||||
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys
|
||||
from app.middleware.audit import AuditMiddleware
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ app.include_router(dashboard.router, prefix="/api/v1")
|
||||
app.include_router(gateway.router, prefix="/api/v1")
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(cron.router, prefix="/api/v1")
|
||||
app.include_router(keys.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Key Manager 服务 — .env 密钥读写、脱敏、批量操作。"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 可管理的密钥白名单
|
||||
WHITELIST_FIELDS = [
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"FEISHU_APP_ID",
|
||||
"FEISHU_APP_SECRET",
|
||||
]
|
||||
|
||||
PROFILES_ROOT = Path(settings.hermes_profiles_root)
|
||||
|
||||
|
||||
def _parse_env(content: str) -> dict[str, str]:
|
||||
"""解析 .env 文件内容为 key-value 字典。"""
|
||||
result: dict[str, str] = {}
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
key, _, val = stripped.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip("\"'")
|
||||
if key in WHITELIST_FIELDS:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def _mask_value(value: str) -> str:
|
||||
"""脱敏: 前3字符 + **...** + 后4字符。"""
|
||||
if len(value) <= 7:
|
||||
return value[:3] + "**...**" + (value[-4:] if len(value) > 3 else "")
|
||||
return value[:3] + "**...**" + value[-4:]
|
||||
|
||||
|
||||
def _read_env_file(profile: str) -> dict[str, str]:
|
||||
"""读取指定 Profile 的 .env 文件中白名单字段。"""
|
||||
env_path = PROFILES_ROOT / profile / ".env"
|
||||
if not env_path.exists():
|
||||
return {}
|
||||
try:
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
return _parse_env(content)
|
||||
except OSError as e:
|
||||
logger.warning("读取 %s/.env 失败: %s", profile, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_env_file(profile: str, updates: dict[str, str]) -> bool:
|
||||
"""写入指定 Profile 的 .env 文件,操作前自动备份。"""
|
||||
env_path = PROFILES_ROOT / profile / ".env"
|
||||
if not env_path.exists():
|
||||
return False
|
||||
|
||||
# 备份
|
||||
try:
|
||||
shutil.copy2(env_path, env_path.with_suffix(".env.bak"))
|
||||
except OSError as e:
|
||||
logger.warning("备份 %s/.env 失败: %s", profile, e)
|
||||
|
||||
try:
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines(keepends=True)
|
||||
updated_keys = set(updates.keys())
|
||||
new_lines: list[str] = []
|
||||
found: set[str] = set()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
if "=" not in stripped:
|
||||
new_lines.append(line)
|
||||
continue
|
||||
key = stripped.partition("=")[0].strip()
|
||||
if key in updated_keys:
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
found.add(key)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
# 追加未找到的字段
|
||||
for key in updated_keys:
|
||||
if key not in found:
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
env_path.write_text("".join(new_lines), encoding="utf-8")
|
||||
logger.info("已写入 %s/.env (%d 个字段)", profile, len(updates))
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("写入 %s/.env 失败: %s", profile, e)
|
||||
return False
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
"""返回所有包含 .env 文件的 Profile 名称列表。"""
|
||||
if not PROFILES_ROOT.exists():
|
||||
return []
|
||||
return sorted(
|
||||
d.name
|
||||
for d in PROFILES_ROOT.iterdir()
|
||||
if d.is_dir() and (d / ".env").exists()
|
||||
)
|
||||
|
||||
|
||||
def get_keys(profile_names: Optional[list[str]] = None) -> list[dict]:
|
||||
"""获取指定 Profile 的密钥列表(脱敏显示)。"""
|
||||
profiles = profile_names if profile_names else list_profiles()
|
||||
result: list[dict] = []
|
||||
for name in profiles:
|
||||
fields = _read_env_file(name)
|
||||
masked = {k: _mask_value(v) for k, v in fields.items()}
|
||||
missing = len(WHITELIST_FIELDS) - len(fields)
|
||||
result.append({"profile": name, "fields": masked, "missing": missing})
|
||||
return result
|
||||
|
||||
|
||||
def batch_overwrite(
|
||||
profile_names: list[str],
|
||||
key_name: str,
|
||||
new_value: str,
|
||||
) -> list[dict]:
|
||||
"""批量覆盖指定 Profile 的某个密钥字段。"""
|
||||
results: list[dict] = []
|
||||
for name in profile_names:
|
||||
success = _write_env_file(name, {key_name: new_value})
|
||||
results.append({"profile": name, "success": success})
|
||||
return results
|
||||
|
||||
|
||||
def batch_scan(profile_names: Optional[list[str]] = None) -> list[dict]:
|
||||
"""批量检查缺失的密钥字段。"""
|
||||
profiles = profile_names if profile_names else list_profiles()
|
||||
results: list[dict] = []
|
||||
for name in profiles:
|
||||
fields = _read_env_file(name)
|
||||
missing = [f for f in WHITELIST_FIELDS if f not in fields]
|
||||
results.append({
|
||||
"profile": name,
|
||||
"missing": missing,
|
||||
"missing_count": len(missing),
|
||||
"total": len(WHITELIST_FIELDS),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_diff(profile_names: list[str]) -> list[dict]:
|
||||
"""差异视图: 横向 Profile, 纵向字段, 值状态(有/无/长度)。"""
|
||||
profiles = profile_names if profile_names else list_profiles()
|
||||
field_map: dict[str, dict[str, str | int]] = {}
|
||||
for name in profiles:
|
||||
fields = _read_env_file(name)
|
||||
for key in WHITELIST_FIELDS:
|
||||
if key not in field_map:
|
||||
field_map[key] = {}
|
||||
if key in fields:
|
||||
field_map[key][name] = len(fields[key])
|
||||
else:
|
||||
field_map[key][name] = 0 # 0 表示缺失
|
||||
rows: list[dict] = []
|
||||
for key in WHITELIST_FIELDS:
|
||||
row: dict[str, str | int] = {"field": key}
|
||||
for name in profiles:
|
||||
val = field_map.get(key, {}).get(name, 0)
|
||||
row[name] = val
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def export_csv(profile_names: Optional[list[str]] = None) -> str:
|
||||
"""CSV 导出批量检查结果。"""
|
||||
scan = batch_scan(profile_names)
|
||||
import csv
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Profile", "缺失字段", "缺失数", "总字段"])
|
||||
for row in scan:
|
||||
writer.writerow([
|
||||
row["profile"],
|
||||
"; ".join(row["missing"]),
|
||||
row["missing_count"],
|
||||
row["total"],
|
||||
])
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user