Files
AgentLab/backend/app/auth/access.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

179 lines
9.1 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.
"""运行、工件的读权限(§3.12「运行、证据、工件(只读)」那一行)。
关登录:都能读。开登录后:
- 只给拥有者的运行(PRIVATE_ORIGINS,docs/design/normal-user-apps.md N7、§3.12「看别人的个人应用内容」一行全是 ✗):
个人应用的刷新、个人蓝图的试运行只有拥有者能读——管理员、搭建者也不例外,运行、证据、工件、文件下载、运行列表、
事件和实时流一个口径(private_owner / hidden_runs);
- builder 及以上:别的运行都能读;
- user 角色只能读:
- 自己发起的运行;
- 自己参与的实例:派给过自己(或自己所在的组)的待办,或者自己回复过的待办所在的运行。派给「任何人」的不算:那是
没有应用绑定的运行(画布、定时任务),user 角色不该因此读到;
- 有授权(use / observe / manage,含 everyone 和自己所在的组)的应用里:视图运行(来源是应用视图、按新口径补算);
observe、manage 授权另能读全部实例;应用发布版本里作为视图固定的工作流版本的正式运行(视图读的是定时任务那次
运行,它不带应用 id)。
user 角色读到的查询快照一律按派生遮罩处理、tool_snapshot 的原文不给(api/artifacts.py),这一层只回答「能不能读」。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
#: 视图运行的来源(engine/origin.py)
_VIEW_ORIGINS = ("app_view", "app_backfill")
#: 只给拥有者读的运行来源:个人应用的刷新(personal_app);试运行(trial)里出自个人蓝图的那些
PRIVATE_ORIGINS = ("personal_app", "trial")
async def private_owner(session: AsyncSession, run: Any) -> tuple[bool, str | None]:
"""这次运行是不是只给拥有者读的,是的话交回拥有者:(是不是, 拥有者)。
- 个人应用的刷新:拥有者按应用上记的认(刷新只能由拥有者发起,这里不靠发起人)。应用不在了、不是个人应用的,拥有者
为 None——谁都读不了,不往宽处猜;
- 试运行:出自个人蓝图的(run.origin 里记的蓝图 id,发起时由服务端写)只给蓝图的拥有者;共享蓝图的试运行照旧按
搭建者读。"""
from app.db.models_apps import App
from app.db.models_blueprints import Blueprint
kind = getattr(run, "origin_kind", None)
if kind == "personal_app":
owner = await session.scalar(select(App.owner).where(App.id == run.app_id, App.scope == "personal")) \
if run.app_id else None
return True, owner
if kind == "trial":
blueprint_id = (run.origin or {}).get("blueprint_id") if isinstance(run.origin, dict) else None
row = (await session.execute(select(Blueprint.scope, Blueprint.owner).where(
Blueprint.id == str(blueprint_id or "")))).first()
if row is not None and row.scope == "personal":
return True, row.owner
return False, None
def hidden_runs(principal: Any) -> Any:
"""SQL 条件:这个人读不了的只给拥有者的运行(别人的个人应用刷新、别人的个人蓝图试运行)。运行列表、审批列表、
探索问题聚类按 not_(hidden_runs(...)) 筛,和 run_readable 同一个口径。关登录时什么都不藏(没有可信的身份)。
各项都按非空比较(coalesce):来源、应用、蓝图 id 为空的老运行不能因为 NULL 被一起筛掉。"""
from sqlalchemy import false, func
from app.db.models import Run
from app.db.models_apps import App
from app.db.models_blueprints import Blueprint
from app.db.portable import json_text
if principal.mode == "off":
return false()
me = principal.username or ""
mine = select(App.id).where(App.scope == "personal", App.owner == me)
others = select(Blueprint.id).where(Blueprint.scope == "personal",
func.coalesce(Blueprint.owner, "") != me)
kind = func.coalesce(Run.origin_kind, "")
return or_(
and_(kind == "personal_app", or_(Run.app_id.is_(None), Run.app_id.not_in(mine))),
and_(kind == "trial", func.coalesce(json_text(Run.origin, "blueprint_id"), "").in_(others)),
)
async def app_roles(session: AsyncSession, principal: Any) -> dict[str, set[str]]:
"""这个人在各应用上的授权:{app_id: {use, observe, manage}}。"""
from app.db.models import AppGrant
who = [AppGrant.principal_kind == "everyone",
and_(AppGrant.principal_kind == "user", AppGrant.principal == principal.username)]
if principal.groups:
who.append(and_(AppGrant.principal_kind == "group", AppGrant.principal.in_(list(principal.groups))))
out: dict[str, set[str]] = {}
for app_id, role in (await session.execute(select(AppGrant.app_id, AppGrant.role).where(or_(*who)))).all():
out.setdefault(app_id, set()).add(role)
return out
async def _participated(session: AsyncSession, principal: Any, run_id: str) -> bool:
from app.db.models import Task, TaskAssignment
mine = [and_(TaskAssignment.principal_kind == "user", TaskAssignment.principal == principal.username),
TaskAssignment.responded_by == principal.username]
if principal.groups:
mine.append(and_(TaskAssignment.principal_kind == "group",
TaskAssignment.principal.in_(list(principal.groups))))
hit = (await session.execute(
select(TaskAssignment.id).join(Task, Task.id == TaskAssignment.task_id)
.where(Task.run_id == run_id, or_(*mine)).limit(1))).first()
return hit is not None
async def _viewed_by_granted_app(session: AsyncSession, run: Any, apps: set[str]) -> bool:
"""这次运行的工作流版本是不是被有授权的应用(当前发布版本)作为视图固定着。"""
from app.db.models import App, AppVersion
if run.run_class != "formal" or not run.workflow_id or run.version is None or not apps:
return False
rows = (await session.execute(
select(AppVersion.pins).join(App, and_(App.id == AppVersion.app_id, App.published_version == AppVersion.version))
.where(App.id.in_(list(apps))))).scalars()
for pins in rows:
for pin in pins or []:
if (isinstance(pin, dict) and pin.get("workflow_id") == run.workflow_id
and pin.get("version") == run.version and pin.get("use", "view") == "view"):
return True
return False
async def run_readable(principal: Any, run: Any) -> bool:
"""能不能读这次运行(证据、事件、工件)。run 可以是 Run,也可以是运行 id。
关登录:能。只给拥有者的运行(private_owner):只有拥有者能读,管理员、搭建者也不例外。builder 及以上:别的都能读,
不存在的运行也答「能」(接口照旧回 404,不改成 403)。user:见模块开头。"""
if principal.mode == "off":
return True
builder = principal.at_least("builder")
if builder and run is not None and not isinstance(run, str) \
and (getattr(run, "origin_kind", None) or "") not in PRIVATE_ORIGINS:
return True
if not builder and not principal.username:
return False
from app.db.base import SessionLocal
from app.db.models import Run
async with SessionLocal() as session:
if isinstance(run, str):
run = await session.get(Run, run)
if run is None:
return builder
private, owner = await private_owner(session, run)
if private:
return bool(owner) and bool(principal.username) and owner == principal.username
if builder:
return True
if run.started_by == principal.username:
return True
if await _participated(session, principal, run.id):
return True
apps = await app_roles(session, principal)
if run.app_id and run.app_id in apps:
if run.origin_kind in _VIEW_ORIGINS or apps[run.app_id] & {"observe", "manage"}:
return True
return await _viewed_by_granted_app(session, run, set(apps))
async def artifact_readable(principal: Any, artifact_id: str) -> bool:
"""能不能读这件工件:引用它的某次运行能读就能读。工件按内容寻址,同一件可能被好几次运行引用。不挂在任何运行上的
工件:builder 及以上照旧能读,user 读不了。只被别人的个人应用刷新、个人蓝图试运行引用的,builder 及以上也读不了。"""
if principal.mode == "off":
return True
from app.db.base import SessionLocal
from app.db.models import Artifact
async with SessionLocal() as session:
run_ids = [r for r in (await session.execute(
select(Artifact.run_id).where(Artifact.id == artifact_id))).scalars() if r]
if not run_ids:
return principal.at_least("builder")
for run_id in run_ids:
if await run_readable(principal, run_id):
return True
return False