Files
AgentLab/backend/app/db/models_apps.py
ThearchyHelios f759c5c82d W3-d 分享链接(后端):只读、不用登录、必设到期、只显示定时出数的视图
为什么做(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 的「嵌入」一节,「分享链接还没有上线」那句改掉。
2026-10-16 16:30:42 +08:00

183 lines
11 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.
"""应用(应用平台 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)