Files
AgentLab/backend/app/api/access_keys.py
T
ThearchyHelios 19329c873e 访问密钥与服务账号的后端(下一轮第 2 波 W2-d,细案 04 A1–A6、总方案 X8)
外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥:

- 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存
  sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥
  24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认
- 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看
  cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409
  keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的
  403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After)
- 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样
- 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从
  process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事;
  有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥
- 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字,
  遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行
- 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、
  保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数
- 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人
  的待办交给任何人
- 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录)

测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
2026-10-16 13:48:09 +08:00

381 lines
20 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.
"""访问密钥与服务账号(docs/design/next-round/04-对外集成与触达.md A1–A7):建、撤销、轮换密钥,建、停用服务账号,个人
访问密钥的开关,每把密钥最近的请求,接口说明。签发、认人、门禁的规则在 app/auth/keys.py。
main.py 挂登录即可(require_role("user")),接口里再按本人 / 管理员判:
- 管理员看得到服务账号和全部密钥,能给服务账号建密钥、撤销和轮换任何一把;
- 每个人看得到自己的个人访问密钥;管理员打开开关(auth.personal_keys,缺省关)之后才能建;
- 关登录时访问密钥不生效:列表照给(空的,带 login_on=false),建密钥、建服务账号回 409(keys_need_login)。
建、撤销、轮换密钥,建、停用服务账号,开关个人密钥都写审计(同一个事务)。密钥明文只在建、轮换的回包里出现一次。
"""
from __future__ import annotations
import re
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import current_principal
from app.auth import keys
from app.auth.audit import record
from app.db.base import get_session, utcnow
from app.db.models_identity import AccessKey, User
router = APIRouter(prefix="/api/access-keys", tags=["access-keys"])
#: 错误码(403 / 404 / 409 / 422)
KEY_NOT_FOUND = "access_key_not_found"
KEY_FORBIDDEN = "access_key_forbidden"
KEY_INVALID_INPUT = "access_key_invalid"
KEY_NOT_ACTIVE = "access_key_not_active"
PERSONAL_KEYS_OFF = "personal_keys_off"
SERVICE_NOT_FOUND = "service_account_not_found"
SERVICE_INVALID = "service_account_invalid"
SERVICE_TAKEN = "service_account_taken"
_SERVICE_NAME = re.compile(r"^svc-[A-Za-z0-9._-]{1,96}$")
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def _need_login() -> None:
if current_principal().mode == "off":
raise CodedHTTPException(409, keys.KEYS_NEED_LOGIN_TEXT, keys.KEYS_NEED_LOGIN)
def _is_admin() -> bool:
principal = current_principal()
return principal.mode != "off" and principal.at_least("admin") and principal.via != "key"
def _require_admin() -> None:
from app.api.deps import ensure_role
ensure_role("admin")
def key_out(row: AccessKey, owner: User | None) -> dict[str, Any]:
"""接口里的一把密钥:没有明文、没有哈希,只有开头一截(prefix)给人认。"""
return {"id": row.id, "name": row.name, "prefix": row.prefix, "scopes": [s for s in row.scopes or []],
"app_ids": list(row.app_ids) if row.app_ids else None,
"owner": {"id": row.owner_user_id, "username": owner.username if owner else None,
"display_name": (owner.display_name or owner.username) if owner else None,
"kind": owner.kind if owner else None},
"personal": owner is None or owner.kind != keys.SERVICE_KIND,
"state": keys.state_of(row, owner),
"expires_at": _iso(row.expires_at), "revoked_at": _iso(row.revoked_at), "revoked_by": row.revoked_by,
"last_used_at": _iso(row.last_used_at), "last_used_ip": row.last_used_ip,
"rotated_from": row.rotated_from, "created_by": row.created_by, "created_at": _iso(row.created_at)}
def service_out(row: User, key_count: int = 0) -> dict[str, Any]:
return {"id": row.id, "username": row.username, "display_name": row.display_name or row.username,
"disabled": row.disabled, "created_by": row.created_by, "created_at": _iso(row.created_at),
"keys": key_count}
async def _owners(session: AsyncSession, rows: list[AccessKey]) -> dict[str, User]:
ids = {r.owner_user_id for r in rows}
if not ids:
return {}
return {u.id: u for u in (await session.execute(select(User).where(User.id.in_(ids)))).scalars()}
# ---------------------------------------------------------------- 开关与概况
@router.get("/settings")
async def get_settings() -> dict[str, Any]:
"""这一页要的几样:开没开登录(关登录时密钥不生效)、个人访问密钥的开关、我能不能建个人密钥、是不是管理员。"""
from app.auth import personal_keys_enabled
principal = current_principal()
login_on = principal.mode != "off"
return {"login_on": login_on, "personal_keys": personal_keys_enabled(), "admin": _is_admin(),
"can_create_personal": login_on and personal_keys_enabled() and bool(principal.user_id)
and principal.via != "key",
"scopes": [*keys.SCOPES], "read_scopes": [*keys.READ_SCOPES], "personal_scopes": [*keys.PERSONAL_SCOPES],
"expires_default_days": keys.EXPIRES_DEFAULT_DAYS, "expires_max_days": keys.EXPIRES_MAX_DAYS}
class SettingsIn(BaseModel):
personal_keys: bool
@router.put("/settings")
async def put_settings(payload: SettingsIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""个人访问密钥的开关(只给管理员)。存在登录设置那一行里(auth.personal_keys),和「使用者可以问数据」同一处缓存。
关掉之后建不了个人密钥,已有的个人密钥也不认(401 key_personal_off),重新打开就又能用。"""
from app.auth import PERSONAL_KEYS, SETTING_KEY, load_settings, remember_settings
from app.db.models import Setting
_need_login()
_require_admin()
before = await load_settings(force=True)
value = {**before, PERSONAL_KEYS: payload.personal_keys}
row = await session.get(Setting, SETTING_KEY)
if row is None:
session.add(Setting(key=SETTING_KEY, value=value))
else:
row.value = value
if bool(before.get(PERSONAL_KEYS)) != payload.personal_keys:
await record(session, "auth.personal_keys", "auth", None,
data={"before": bool(before.get(PERSONAL_KEYS)), "after": payload.personal_keys})
await session.commit()
remember_settings(value)
return await get_settings()
@router.get("/reference")
async def reference() -> dict[str, Any]:
"""接口说明(A7):从门禁那张表(auth/keys.KEY_ROUTES)生成,方法、路径、scope、说明、可能的错误码。要登录(main.py 挂的
require_role("user")),不对匿名的人敞开。"""
return keys.reference()
# ---------------------------------------------------------------- 服务账号(只给管理员)
@router.get("/service-accounts")
async def list_service_accounts(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
_require_admin()
rows = list((await session.execute(select(User).where(User.kind == keys.SERVICE_KIND)
.order_by(User.created_at))).scalars())
counts: dict[str, int] = {}
for owner_id in (await session.execute(select(AccessKey.owner_user_id).where(
AccessKey.owner_user_id.in_([r.id for r in rows] or [""]), AccessKey.revoked_at.is_(None)))).scalars():
counts[owner_id] = counts.get(owner_id, 0) + 1
return {"items": [service_out(r, counts.get(r.id, 0)) for r in rows]}
class ServiceIn(BaseModel):
username: str = Field(min_length=5, max_length=100)
display_name: str | None = Field(default=None, max_length=100)
@router.post("/service-accounts", status_code=201)
async def create_service_account(payload: ServiceIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""建一个服务账号:用户名统一 svc- 开头,没有口令、不能登录,角色固定使用者。它就是一个用户——授权、组、名录全部沿用
现有的机制(应用授权里能选它;岗位绑定、选人候选里排除它:服务账号不能被派待办)。"""
_need_login()
_require_admin()
username = payload.username.strip()
if not _SERVICE_NAME.match(username):
raise CodedHTTPException(422, "服务账号的用户名要以 svc- 开头,后面只用字母、数字、点、下划线和短横线",
SERVICE_INVALID, field="username")
if (await session.execute(select(User.id).where(User.username == username))).first():
raise CodedHTTPException(409, f"用户名「{username}」已存在", SERVICE_TAKEN, field="username")
row = User(username=username, display_name=(payload.display_name or "").strip() or username, role="user",
kind=keys.SERVICE_KIND, password_hash=None, created_by=current_principal().username)
session.add(row)
try:
await session.flush()
except IntegrityError as e:
await session.rollback()
raise CodedHTTPException(409, f"用户名「{username}」已存在", SERVICE_TAKEN, field="username") from e
await record(session, "service_account.create", "user", row.id, data={"username": row.username})
await session.commit()
return service_out(row)
class ServicePatch(BaseModel):
display_name: str | None = Field(default=None, max_length=100)
disabled: bool | None = None
async def _service(session: AsyncSession, user_id: str) -> User:
row = await session.get(User, user_id)
if row is None or row.kind != keys.SERVICE_KIND:
raise CodedHTTPException(404, "服务账号不存在", SERVICE_NOT_FOUND)
return row
@router.patch("/service-accounts/{user_id}")
async def update_service_account(user_id: str, payload: ServicePatch,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""改显示名、停用 / 启用。停用之后它的密钥全部失效(401 key_owner_disabled),重新启用就又能用(没到期、没撤销的)。"""
_need_login()
_require_admin()
row = await _service(session, user_id)
changes: dict[str, Any] = {}
if payload.display_name is not None and payload.display_name.strip() != row.display_name:
changes["display_name"] = [row.display_name, payload.display_name.strip() or row.username]
row.display_name = payload.display_name.strip() or row.username
if payload.disabled is not None and payload.disabled != row.disabled:
changes["disabled"] = [row.disabled, payload.disabled]
row.disabled = payload.disabled
if changes:
action = ("service_account.disable" if payload.disabled else "service_account.enable") \
if "disabled" in changes else "service_account.update"
await record(session, action, "user", row.id, data={"username": row.username, **changes})
await session.commit()
return service_out(row)
# ---------------------------------------------------------------- 密钥
@router.get("")
async def list_keys(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""管理员:全部密钥(服务账号的和每个人的个人密钥);别人:自己的个人密钥。关登录时是空的。"""
principal = current_principal()
if principal.mode == "off" or not principal.user_id:
return {"items": []}
query = select(AccessKey).order_by(AccessKey.created_at.desc())
if not _is_admin():
query = query.where(AccessKey.owner_user_id == principal.user_id)
rows = list((await session.execute(query)).scalars())
owners = await _owners(session, rows)
return {"items": [key_out(r, owners.get(r.owner_user_id)) for r in rows]}
class KeyIn(BaseModel):
name: str = Field(min_length=1, max_length=keys.NAME_MAX)
scopes: list[str] = Field(default_factory=lambda: list(keys.READ_SCOPES))
#: 不给(或空)是不限应用
app_ids: list[str] | None = None
expires_in_days: int = Field(default=keys.EXPIRES_DEFAULT_DAYS, ge=1, le=keys.EXPIRES_MAX_DAYS)
#: 给服务账号建(只给管理员);不给是建自己的个人访问密钥
service_account_id: str | None = None
async def _check_input(session: AsyncSession, scopes: list[str], app_ids: list[str] | None, *,
service: bool) -> tuple[list[str], list[str] | None]:
"""scope 只能是认得的那几个(至少一个);服务账号不能勾只给个人密钥的(回复待办)。限定的应用要是在的、共享的应用。"""
from app.db.models_apps import App
picked = list(dict.fromkeys(s for s in scopes))
if not picked or any(s not in keys.SCOPES for s in picked):
raise CodedHTTPException(422, f"权限只能从 {'、'.join(keys.SCOPES)} 里选,至少选一项", KEY_INVALID_INPUT,
field="scopes")
if service and (bad := [s for s in picked if s in keys.PERSONAL_SCOPES]):
raise CodedHTTPException(422, f"服务账号不能被派待办,它的密钥不能有「{'、'.join(bad)}」:外部系统回调请用应用的 signal 钩子",
KEY_INVALID_INPUT, field="scopes")
apps = list(dict.fromkeys(str(a).strip() for a in app_ids or [] if str(a).strip())) or None
if apps is not None:
if len(apps) > keys.APPS_MAX:
raise CodedHTTPException(422, f"一把密钥最多限定 {keys.APPS_MAX} 个应用", KEY_INVALID_INPUT, field="app_ids")
found = {a.id: a for a in (await session.execute(select(App).where(App.id.in_(apps)))).scalars()}
missing = [a for a in apps if a not in found]
personal = [found[a].name for a in apps if a in found and found[a].scope == "personal"]
if missing:
raise CodedHTTPException(422, f"限定的应用不存在:{'、'.join(missing)}", KEY_INVALID_INPUT, field="app_ids")
if personal:
raise CodedHTTPException(422, f"访问密钥不能限定到个人应用:{'、'.join(personal)}", KEY_INVALID_INPUT,
field="app_ids")
return picked, apps
async def _issue(session: AsyncSession, *, owner: User, name: str, scopes: list[str], app_ids: list[str] | None,
days: float, rotated_from: str | None = None) -> tuple[AccessKey, str]:
secret = keys.new_secret()
row = AccessKey(owner_user_id=owner.id, name=name, key_hash=keys.key_hash(secret), prefix=keys.prefix_of(secret),
scopes=scopes, app_ids=app_ids, expires_at=utcnow() + timedelta(days=days),
rotated_from=rotated_from, created_by=current_principal().username)
session.add(row)
await session.flush()
return row, secret
@router.post("", status_code=201)
async def create_key(payload: KeyIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""建一把密钥。明文(agl_…)只在这一次的回包里(key);库里只存哈希和开头一截。"""
from app.auth import personal_keys_enabled
_need_login()
principal = current_principal()
if principal.via == "key":
raise CodedHTTPException(403, "不能用访问密钥建访问密钥", KEY_FORBIDDEN)
if payload.service_account_id:
_require_admin()
owner = await _service(session, payload.service_account_id)
if owner.disabled:
raise CodedHTTPException(409, f"服务账号「{owner.username}」已停用,先启用再建密钥", KEY_NOT_ACTIVE)
else:
if not personal_keys_enabled():
raise CodedHTTPException(409, "管理员还没有打开个人访问密钥:系统间集成请让管理员建服务账号的密钥",
PERSONAL_KEYS_OFF)
owner = await session.get(User, principal.user_id) if principal.user_id else None
if owner is None:
raise CodedHTTPException(409, "找不到你的账号,请重新登录", KEY_FORBIDDEN)
scopes, apps = await _check_input(session, payload.scopes, payload.app_ids,
service=owner.kind == keys.SERVICE_KIND)
row, secret = await _issue(session, owner=owner, name=payload.name.strip() or "访问密钥", scopes=scopes,
app_ids=apps, days=payload.expires_in_days)
await record(session, "access_key.create", "access_key", row.id,
data={"name": row.name, "owner": owner.username, "owner_kind": owner.kind, "prefix": row.prefix,
"scopes": scopes, "app_ids": apps, "expires_at": _iso(row.expires_at)})
await session.commit()
return {**key_out(row, owner), "key": secret}
async def _own_or_admin(session: AsyncSession, key_id: str) -> tuple[AccessKey, User | None]:
"""这把密钥能不能由这个人管:管理员能管任何一把;别人只能管自己的。看不到的一律当不存在(404)。"""
_need_login()
principal = current_principal()
if principal.via == "key":
raise CodedHTTPException(403, "不能用访问密钥管理访问密钥", KEY_FORBIDDEN)
row = await session.get(AccessKey, key_id)
if row is None or (not _is_admin() and row.owner_user_id != principal.user_id):
raise CodedHTTPException(404, "访问密钥不存在", KEY_NOT_FOUND)
return row, await session.get(User, row.owner_user_id)
@router.post("/{key_id}/revoke")
async def revoke_key(key_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""撤销:立刻失效(401 key_revoked),不能恢复。撤销过的再撤销一次照样回成功(不再改时间)。"""
row, owner = await _own_or_admin(session, key_id)
if row.revoked_at is None:
row.revoked_at = utcnow()
row.revoked_by = current_principal().username
await record(session, "access_key.revoke", "access_key", row.id,
data={"name": row.name, "owner": owner.username if owner else None, "prefix": row.prefix})
await session.commit()
return key_out(row, owner)
@router.post("/{key_id}/rotate")
async def rotate_key(key_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""轮换:签一把新的(名字、权限、限定的应用、有效期长短照旧),旧的 24 小时后到期——换钥匙不用停机。新密钥的明文只在
这一次的回包里(key)。撤销了的、过期了的、已经轮换过的不能再轮换(新建一把)。"""
from app.auth import personal_keys_enabled
row, owner = await _own_or_admin(session, key_id)
state = keys.state_of(row, owner)
if state != "active":
raise CodedHTTPException(409, "这把密钥已撤销、过期或拥有者已停用,不能轮换:请新建一把", KEY_NOT_ACTIVE)
if await session.scalar(select(AccessKey.id).where(AccessKey.rotated_from == row.id).limit(1)):
raise CodedHTTPException(409, "这把密钥已经轮换过:请用轮换出来的那一把", KEY_NOT_ACTIVE)
if owner is None:
raise CodedHTTPException(404, "访问密钥不存在", KEY_NOT_FOUND)
if owner.kind != keys.SERVICE_KIND and not personal_keys_enabled():
raise CodedHTTPException(409, "管理员已关闭个人访问密钥,不能轮换", PERSONAL_KEYS_OFF)
span = keys.aware(row.expires_at) - keys.aware(row.created_at)
days = min(max(span.total_seconds() / 86400, 1.0), float(keys.EXPIRES_MAX_DAYS))
fresh, secret = await _issue(session, owner=owner, name=row.name, scopes=list(row.scopes or []),
app_ids=list(row.app_ids) if row.app_ids else None, days=days, rotated_from=row.id)
grace = utcnow() + keys.ROTATE_GRACE
if keys.aware(row.expires_at) > grace:
row.expires_at = grace
await record(session, "access_key.rotate", "access_key", row.id,
data={"name": row.name, "owner": owner.username, "prefix": row.prefix, "new_key": fresh.id,
"new_prefix": fresh.prefix, "old_expires_at": _iso(row.expires_at)})
await session.commit()
return {**key_out(fresh, owner), "key": secret, "previous": key_out(row, owner)}
@router.get("/{key_id}/requests")
async def key_requests(key_id: str, limit: int = 100, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""这把密钥最近的请求(新的在前,最多 100 条):时间、方法、接口(路由模板)、状态码、涉及的运行、来源地址。"""
row, _ = await _own_or_admin(session, key_id)
return {"items": await keys.recent_requests(session, row.id, min(max(limit, 1), 100)),
"retain_days": keys.RETAIN.days}