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

149 lines
8.6 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.
"""应用(应用平台 A1–A3,docs/design/app-platform-and-look.md §2.1–§2.3、§3.1–§3.6)。
App 可变(名称、草稿、授权);AppVersion 不可变,只在发布时生成:spec(agentlab.app/1)、钉住的工作流版本(pins)、
岗位与钩子的绑定(bindings)、级别(取各钉住项里最低的那一级)、spec_hash、钉住项升版的处置(dispositions)。
授权(app_grants)和组成员不进版本,改动写审计。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow
#: 应用授权:use 能用(入口、视图、自己的实例);observe 另能看全部实例;manage 能搭建、发布、改派
GRANT_ROLES = ("use", "observe", "manage")
class App(Base, TimestampMixin):
__tablename__ = "apps"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
icon: Mapped[str] = mapped_column(String(40), default="app")
# 编辑中的 spec(agentlab.app/1),发布前随便改;发布时冻结成一个 AppVersion
draft: Mapped[dict[str, Any]] = mapped_column(default=dict)
# 草稿里的岗位、钩子、通知渠道绑定(发布时一并冻结进 AppVersion.bindings)
draft_bindings: Mapped[dict[str, Any]] = mapped_column(default=dict)
published_version: Mapped[int | None] = mapped_column(Integer, default=None)
status: Mapped[str] = mapped_column(String(20), default="active") # active | archived
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
updated_by: Mapped[str | None] = mapped_column(String(100), default=None)
# 普通用户建应用(N7):personal 是使用者自己的个人应用,只有拥有者看得见、永远跑探索运行、没有应用版本
scope: Mapped[str] = mapped_column(String(20), default="shared") # shared | personal
owner: Mapped[str | None] = mapped_column(String(100), default=None)
# 出自哪个蓝图(共享应用经发布单生成、个人应用存蓝图时生成)
blueprint_id: Mapped[str | None] = mapped_column(String(32), default=None)
class AppVersion(Base):
__tablename__ = "app_versions"
__table_args__ = (UniqueConstraint("app_id", "version"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
version: Mapped[int] = mapped_column(Integer)
spec: Mapped[dict[str, Any]] = mapped_column(default=dict)
spec_hash: Mapped[str] = mapped_column(String(64))
# [{alias, workflow_id, version, graph_hash, level, use}],和 spec.pins 一致(冗余存一份便于反查)
pins: Mapped[list[Any]] = mapped_column(default=list)
# {"slots": {岗位: [{kind: user|group, name}]}, "hooks": {...}, "channels": {...}}
bindings: Mapped[dict[str, Any]] = mapped_column(default=dict)
level: Mapped[str] = mapped_column(String(20), default="published") # published | governed
# 钉住项相对上一个应用版本升了版时选的处置:{alias: "recompute" | "dual" | "incomparable"}
dispositions: Mapped[dict[str, Any]] = mapped_column(default=dict)
note: Mapped[str] = mapped_column(Text, default="")
published_by: Mapped[str | None] = mapped_column(String(100), default=None)
published_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class AppGrant(Base):
__tablename__ = "app_grants"
__table_args__ = (UniqueConstraint("app_id", "principal_kind", "principal", "role"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
principal_kind: Mapped[str] = mapped_column(String(20)) # user | group | everyone
principal: Mapped[str] = mapped_column(String(100), default="")
role: Mapped[str] = mapped_column(String(20), default="use")
granted_by: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class AppViewCache(Base):
"""按需视图的缓存。key = sha256(graph_hash, 规范化后的输入, data_versions, 能力版本)。"""
__tablename__ = "app_view_cache"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
app_id: Mapped[str] = mapped_column(String(32), index=True)
view_id: Mapped[str] = mapped_column(String(100))
run_id: Mapped[str] = mapped_column(String(32))
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
expires_at: Mapped[datetime | None] = mapped_column(default=None)
class AppHook(Base, TimestampMixin):
"""入站钩子。start:按入口发起实例;signal:按 correlation 解决一条「等外部系统」的待办。"""
__tablename__ = "app_hooks"
# 一个名字一行(D-g):并发「生成密钥」不会多出第二把能用的密钥。存量库由 db/base.py 的 _UNIQUE_INDEX_MIGRATIONS 补建
__table_args__ = (Index("ux_app_hooks_app_name", "app_id", "name", unique=True),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(100))
kind: Mapped[str] = mapped_column(String(20)) # start | signal
entry_id: Mapped[str | None] = mapped_column(String(100), default=None)
secret: Mapped[str] = mapped_column(Text) # 密文
enabled: Mapped[bool] = mapped_column(default=True)
class HookReceipt(Base):
"""入站请求按 Idempotency-Key 去重的记录。"""
__tablename__ = "hook_receipts"
__table_args__ = (UniqueConstraint("hook_id", "idem_key"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
hook_id: Mapped[str] = mapped_column(ForeignKey("app_hooks.id", ondelete="CASCADE"), index=True)
idem_key: Mapped[str] = mapped_column(String(200))
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
task_id: Mapped[str | None] = mapped_column(String(32), default=None)
# processing(按幂等键认领、处理中)| accepted | rejected | signature(idem_key 以 sig: 开头:记一个签名用过了,防换键重放)
status: Mapped[str] = mapped_column(String(20))
detail: Mapped[str | None] = mapped_column(Text, default=None)
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class ApiReceipt(Base):
"""用访问密钥发起实例、刷新视图时按 Idempotency-Key 去重的记录(docs/design/next-round/04-对外集成与触达.md A4)。
按 (拥有者, 幂等键) 唯一,不按密钥:轮换了密钥,同一个键照样认得出是同一件事。状态机和钩子的收件记录是同一套
(process/receipts.py):processing 认领、处理中 → accepted / rejected;停在 processing 太久的可以重新认领。钩子那张表
(hook_receipts.hook_id 非空外键,SQLite 改不了可空性)不动,两张表各存各的、共用状态机。
fingerprint 是这次请求的指纹(方法、路由模板、路径参数、请求体):同一个键拿去办另一件事(换了入口、换了表单值)不交回
上一件的结果,照实拒掉——钩子的键只在一个钩子里用,这里一个键跨所有应用和入口,撞键的可能大得多。"""
__tablename__ = "api_receipts"
__table_args__ = (UniqueConstraint("owner_user_id", "idem_key"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
owner_user_id: Mapped[str] = mapped_column(String(32), index=True)
idem_key: Mapped[str] = mapped_column(String(200))
#: 最近一次认领它的那把密钥(轮换前后可能不同)
key_id: Mapped[str | None] = mapped_column(String(32), default=None)
route: Mapped[str] = mapped_column(String(300), default="")
fingerprint: Mapped[str | None] = mapped_column(String(64), default=None)
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
task_id: Mapped[str | None] = mapped_column(String(32), default=None)
# processing | accepted | rejected
status: Mapped[str] = mapped_column(String(20))
detail: Mapped[str | None] = mapped_column(Text, default=None)
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)