mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:41:57 +00:00
f759c5c82d
为什么做(04 细案 C2,集成-I6):会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上,想把看板放进别的门户只能每人开账号且
必须同站;分享链接是跨站嵌入唯一走得通的办法。它把数据交出了授权范围,所以每一步往窄处取:
- view_shares(db/models_apps.ViewShare):只存 key 的 sha256 和开头 6 位;latest 跟着最近一次封存走,fixed 存建分享那一刻
每个视图的运行 id、按建分享时的应用版本解析;到期必填;打开只计数
- POST / GET / DELETE /api/apps/{id}/shares:要应用的 manage,建要管理员开了 apps.share_links(缺省关);个人应用、没发布、
归档的不能分享;这一页没有最近封存的视图不收;到期最长 90 天;key 只在建的时候交回一次(/s#<key>);建、撤销写审计
- GET /api/shares/page、/api/shares/report.html 是公开路由(中间件把 /api/shares/ 当公开前缀),只认请求头
X-AgentLab-Share,地址里带的 key 不认;撤销、到期回 410 写明;开关关掉时已有的一并 403;应用归档、页面没了 410
- apps/resolve.resolve_page 加 share 旁路:只解析 latest_sealed 视图(fixed 用存下的运行,趋势也停在那一期),按需、实例、
探索视图写明原因、从不发起运行(auto 的也不);流程组件和报告换成写明原因的说明块;页面缓存键另加分享的标识;out 另给
各视图取到的运行和可下载的报告
- 下载报告只认这条分享此刻解析出来的运行,内部就是 export_report_html(同样遮过、照常记导出审计,导出人记成不冒充任何人的
分享身份)
- 分享接口都不放给访问密钥(不在 KEY_ROUTES,门禁 403)
安全头:/s 用 AGENTLAB_EMBED_ORIGINS 和 no-referrer(第 1 波 web_security 预留的规则,这次在真实页面托管下测)。C3 同站门户
嵌整站只做配置和文档:写进 web_security 的模块说明和 deploy/agentlab.env 的「嵌入」一节,「分享链接还没有上线」那句改掉。
183 lines
11 KiB
Python
183 lines
11 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)
|
||
|
||
|
||
class ViewShare(Base):
|
||
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,
|
||
可以嵌进门户)。
|
||
|
||
链接写成 /s#<分享 key>:key 在 # 后面,打开页面时不发给服务器、不进访问日志和 Referer,页面读出来再放进请求头
|
||
X-AgentLab-Share(api/shares.py)。库里只存 key 的 sha256(key_hash)和开头一截(prefix,认是哪一条),同访问密钥。
|
||
|
||
mode:latest 始终取最近一次封存;fixed 取建分享那一刻每个视图的运行(runs:{视图 id: 运行 id},还没有封存运行的是
|
||
null),解析建分享时的应用版本(app_version)。到期必填、最长 90 天;撤销、到期之后打开回 410 并写明。建、撤销写审计;
|
||
打开只计数(open_count、last_opened_at),不一次写一行。embed_allowed 为假时分享页不肯在别的页面的 iframe 里显示(服务端
|
||
在 /s 上只能按全站的嵌入白名单 AGENTLAB_EMBED_ORIGINS 发头:打开页面那一刻还不知道是哪一条分享)。"""
|
||
|
||
__tablename__ = "view_shares"
|
||
|
||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||
prefix: Mapped[str] = mapped_column(String(16))
|
||
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
|
||
page_id: Mapped[str] = mapped_column(String(100))
|
||
mode: Mapped[str] = mapped_column(String(20), default="latest") # latest | fixed
|
||
app_version: Mapped[int | None] = mapped_column(Integer, default=None)
|
||
runs: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||
embed_allowed: Mapped[bool] = mapped_column(default=False)
|
||
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||
#: 建分享的人当时的显示名:分享页页脚写「由 X 分享」
|
||
created_by_name: Mapped[str | None] = mapped_column(String(100), default=None)
|
||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||
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)
|
||
open_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
last_opened_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|