mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:01:58 +00:00
19329c873e
外部系统(门户、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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
148 lines
8.2 KiB
Python
148 lines
8.2 KiB
Python
"""身份:用户、组、会话、审计(应用平台 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)
|