Files
AgentLab/backend/app/db/models_identity.py
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

148 lines
8.2 KiB
Python
Raw Permalink 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.
"""身份:用户、组、会话、审计(应用平台 A7,docs/design/app-platform-and-look.md §2.7、§3.1)。
登录方式有三种:off(默认,今天的行为:没有登录,X-Actor 自报署名)、local(用户名 + 密码)、oidc。
off 下这些表都是空的,任何接口都不读它们——关登录时的行为必须和加这些表之前一模一样。
username 一经建立就不改:各「人」列(runs.started_by、approvals.resolved_by、workflows.published_by……)开了登录
就写它,改名会让历史记录对不上人。要换叫法改 display_name。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow
#: 角色由高到低:admin ⊇ builder ⊇ user
ROLES = ("admin", "builder", "user")
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
username: Mapped[str] = mapped_column(String(100), unique=True)
display_name: Mapped[str] = mapped_column(String(100), default="")
email: Mapped[str | None] = mapped_column(String(200), default=None)
role: Mapped[str] = mapped_column(String(20), default="user")
# person 人;service 服务账号(next-round 04 A1):管理员建,没有口令、不能登录,只持有访问密钥
kind: Mapped[str] = mapped_column(String(20), default="person")
# local 模式:hashlib.scrypt 的结果(含盐和参数);OIDC 用户为空
password_hash: Mapped[str | None] = mapped_column(Text, default=None)
oidc_issuer: Mapped[str | None] = mapped_column(String(300), default=None)
# (issuer, subject) 认一个 OIDC 用户;只看 subject 会把两个身份源里同号的人当成一个
oidc_subject: Mapped[str | None] = mapped_column(String(200), default=None)
disabled: Mapped[bool] = mapped_column(default=False)
last_login_at: Mapped[datetime | None] = mapped_column(default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
__table_args__ = (UniqueConstraint("oidc_issuer", "oidc_subject"),)
class Group(Base, TimestampMixin):
"""组:岗位绑定、应用授权都可以落到组上。source=oidc 的由登录时按 groups 声明同步,界面上只读。"""
__tablename__ = "groups"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
label: Mapped[str] = mapped_column(String(100), default="")
source: Mapped[str] = mapped_column(String(20), default="local") # local | oidc
class GroupMember(Base):
__tablename__ = "group_members"
__table_args__ = (UniqueConstraint("group_id", "user_id"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
added_by: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class AuthSession(Base):
"""登录会话。库里只存令牌的 sha256,令牌本身只在 HttpOnly cookie 里。"""
__tablename__ = "auth_sessions"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime)
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
revoked_at: Mapped[datetime | None] = mapped_column(default=None)
ip: Mapped[str | None] = mapped_column(String(64), default=None)
user_agent: Mapped[str | None] = mapped_column(String(300), default=None)
class AuditEvent(Base):
"""管理类操作的留痕:登录方式、角色、组、授权、应用发布、端点、能力治理、版本退役。
verified 记这一条的操作人是不是认证过的身份(off 模式下是自报的署名,记 False)。
"""
__tablename__ = "audit_events"
__table_args__ = (Index("ix_audit_events_subject", "subject_kind", "subject_id"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, index=True)
actor: Mapped[str | None] = mapped_column(String(100), default=None)
verified: Mapped[bool] = mapped_column(default=False)
action: Mapped[str] = mapped_column(String(60))
subject_kind: Mapped[str] = mapped_column(String(40))
subject_id: Mapped[str | None] = mapped_column(String(64), default=None)
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
class AccessKey(Base):
"""访问密钥(docs/design/next-round/04-对外集成与触达.md A1):外部系统、个人脚本调接口用的不记名凭据。
库里只存密钥的 sha256(key_hash)和明文开头那一截(prefix,「agl_」加随机串的前 8 位,界面上认是哪一把):库被拷走
也拿不到能用的密钥。到期必填(最长 365 天);撤销、到期、拥有者停用之后,下一个请求就认不出来。拥有者可以是人(个人
访问密钥,管理员开关 auth.personal_keys)或服务账号(users.kind=service,管理员建)。
scopes 是这把密钥能调的几类接口(auth/keys.SCOPES);app_ids 为空表示不限应用,否则只认这几个应用。不论拥有者是谁,
按密钥认出来的身份一律封顶在使用者的权限和遮罩边界(A3)。轮换时签一把新的、旧的 24 小时后到期(rotated_from 记新钥
从哪一把换来)。"""
__tablename__ = "access_keys"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(100))
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
prefix: Mapped[str] = mapped_column(String(16))
scopes: Mapped[list[Any]] = mapped_column(default=list)
app_ids: Mapped[list[Any] | None] = mapped_column(default=None)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
revoked_by: Mapped[str | None] = mapped_column(String(100), default=None)
last_used_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
last_used_ip: Mapped[str | None] = mapped_column(String(64), default=None)
rotated_from: Mapped[str | None] = mapped_column(String(32), default=None)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class AccessKeyRequest(Base):
"""用访问密钥发来的请求(A6):哪一把、什么时候、哪个接口(路由模板,不是带参数的路径)、回了什么状态、涉及哪次运行、
从哪个地址来。先在进程内攒着,调度循环每一拍(或攒满一批)批量写一次:每个请求同步写一行会和运行抢 SQLite 的写锁。
保留 90 天,过期的在调度循环里清掉。"""
__tablename__ = "access_key_requests"
__table_args__ = (Index("ix_access_key_requests_key_at", "key_id", "at"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
key_id: Mapped[str] = mapped_column(String(32))
at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, index=True)
method: Mapped[str] = mapped_column(String(10))
route: Mapped[str] = mapped_column(String(300))
status: Mapped[int] = mapped_column(default=0)
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
ip: Mapped[str | None] = mapped_column(String(64), default=None)