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