mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:11:58 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
310 lines
16 KiB
Python
310 lines
16 KiB
Python
"""请求里的「人」:身份与权限的唯一入口(docs/design/app-platform-and-look.md §2.7、附录 C)。
|
||
|
||
登录方式三种:off(默认,今天的行为)、local、oidc。各接口不直接碰请求头或会话,只问这里:
|
||
- actor_of(header):记在各「人」列上的名字。关登录时照旧解码 X-Actor(自报署名);开了登录返回认证过的
|
||
用户名,请求头一律不看——这样各接口的签名一个都不用改;
|
||
- current_principal():完整身份(用户名、角色、组、是否认证过、登录方式),中间件按请求写进 contextvar;
|
||
- require_role(role):挂在 main.py 的 include_router(..., dependencies=[...]) 上的路由级依赖;
|
||
- can_read_run / can_read_artifact:运行、工件的读权限(user 角色只能读有授权的应用视图运行、自己参与的实例);
|
||
- ensure_role / ensure_can_publish:接口内部按请求内容判定的权限(MCP 的 stdio、受管级发布、版本退役,Q8);
|
||
- restricted():这次请求是不是开登录后的 user 角色(按请求内容收窄的接口用:问数据只能发起探索运行、工件按白名单给);
|
||
- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的);
|
||
- ensure_conversation_access(session, conversation_id):接受 conversation_id 的接口核对对话归属(对话按人隔离);
|
||
- run_scope / evidence_scope / chat_scope:main.py 挂在运行、证据、问数据几组路由上的依赖,按 run_id 判断能不能读。
|
||
run_scope 同样管运行的 WebSocket,握手时按运行判。
|
||
|
||
关登录时:所有人等同 admin、全部放行,行为和加这一层之前一模一样(界面上挂横幅说明)。
|
||
|
||
开登录(local / oidc)的实现由 app/auth/ 提供:会话、中间件、OIDC、引导第一个管理员。这个模块只定接口。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from contextvars import ContextVar
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
from urllib.parse import unquote
|
||
|
||
from fastapi import Depends, HTTPException
|
||
from starlette.requests import HTTPConnection
|
||
|
||
#: 登录方式
|
||
AUTH_MODES = ("off", "local", "oidc")
|
||
#: 角色由高到低
|
||
ROLE_RANK = {"user": 1, "builder": 2, "admin": 3}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Principal:
|
||
"""这次请求是谁。
|
||
|
||
username:开登录时是认证过的用户名(不可改);关登录时是自报的署名,可能为空。
|
||
verified:是不是认证过的身份。关登录时恒为 False——审计、派给人的记录、run.origin 照实记。
|
||
"""
|
||
|
||
username: str | None
|
||
role: str = "admin"
|
||
display_name: str | None = None
|
||
user_id: str | None = None
|
||
groups: tuple[str, ...] = field(default_factory=tuple)
|
||
verified: bool = False
|
||
mode: str = "off"
|
||
|
||
def at_least(self, role: str) -> bool:
|
||
return ROLE_RANK.get(self.role, 0) >= ROLE_RANK.get(role, 99)
|
||
|
||
|
||
#: 中间件按请求写入(app/auth/)。没写(关登录、测试直接调函数)时为 None
|
||
_principal: ContextVar[Principal | None] = ContextVar("agentlab_principal", default=None)
|
||
|
||
|
||
def set_principal(principal: Principal | None) -> Any:
|
||
"""中间件用:写入这次请求的身份,返回 token 供 reset。"""
|
||
return _principal.set(principal)
|
||
|
||
|
||
def reset_principal(token: Any) -> None:
|
||
_principal.reset(token)
|
||
|
||
|
||
def auth_mode() -> str:
|
||
"""当前的登录方式。环境变量 AGENTLAB_AUTH_MODE 优先于设置页(app/auth/ 实现读取与缓存)。"""
|
||
from app.auth import current_mode
|
||
|
||
return current_mode()
|
||
|
||
|
||
def decode_actor(header: str | None) -> str | None:
|
||
"""请求头 X-Actor 里的署名(设置页填的那个),没填就是 None。
|
||
|
||
浏览器的请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求
|
||
发不出去。所以前端按 encodeURIComponent 编码后发,这里解回来;老客户端发的
|
||
纯 ASCII 署名解码前后一样。
|
||
"""
|
||
value = unquote((header or "").strip()).strip()
|
||
return value[:100] or None
|
||
|
||
|
||
def actor_of(header: str | None) -> str | None:
|
||
"""记在各「人」列上的名字。开了登录:认证过的用户名,请求头不看;关登录:解码 X-Actor。"""
|
||
principal = _principal.get()
|
||
if principal is not None and principal.mode != "off":
|
||
return principal.username
|
||
return decode_actor(header)
|
||
|
||
|
||
def signer_of(given: Any, header: str | None, limit: int = 100) -> str | None:
|
||
"""请求体里自带署名(signed_by)的留痕记谁:开了登录一律是认证过的用户名,请求体里填的不看——不然随手填一个名字就
|
||
盖掉了登录的人;关登录时请求体里填的优先(截到 limit 个字),其次 X-Actor,都只原样记下(未认证)。"""
|
||
principal = _principal.get()
|
||
if principal is not None and principal.mode != "off":
|
||
return principal.username
|
||
if isinstance(given, str) and given.strip():
|
||
return given.strip()[:limit]
|
||
return decode_actor(header)
|
||
|
||
|
||
def current_principal() -> Principal:
|
||
"""这次请求的完整身份。关登录时:等同 admin、未认证,username 是中间件按 X-Actor 解出的自报署名;中间件没写
|
||
(不在请求里,比如测试直接调函数、启动时的整理、调度循环)时同样等同 admin、未认证,username 为空。"""
|
||
principal = _principal.get()
|
||
if principal is not None:
|
||
return principal
|
||
return Principal(username=None, role="admin", verified=False, mode="off")
|
||
|
||
|
||
def actor_verified() -> bool:
|
||
return current_principal().verified
|
||
|
||
|
||
def require_role(role: str) -> Any:
|
||
"""路由级依赖:至少是 role。关登录时放行。开登录时:没登录 401,角色不够 403(app/auth/ 实现)。"""
|
||
from app.auth import role_dependency
|
||
|
||
return role_dependency(role)
|
||
|
||
|
||
async def can_read_run(principal: Principal, run: Any) -> bool:
|
||
"""能不能读这次运行(证据、事件、工件)。关登录或 builder 以上:能。user:有授权的应用视图运行、自己参与的实例。"""
|
||
from app.auth import run_readable
|
||
|
||
return await run_readable(principal, run)
|
||
|
||
|
||
async def can_read_artifact(principal: Principal, artifact_id: str) -> bool:
|
||
"""能不能读这件工件:它属于的某次运行能读就能读。"""
|
||
from app.auth import artifact_readable
|
||
|
||
return await artifact_readable(principal, artifact_id)
|
||
|
||
|
||
def ensure_role(role: str) -> None:
|
||
"""接口内部用:至少是 role,否则 401(没登录)/ 403(角色不够)。关登录时放行。
|
||
用在要看请求内容才知道要什么角色的地方(比如 MCP 的 stdio 只有管理员能建)。"""
|
||
from app.auth import require
|
||
|
||
require(role)
|
||
|
||
|
||
def ensure_can_publish(level: str | None) -> None:
|
||
"""发布工作流(Q8):受管级只给管理员,已发布级给搭建者及以上。版本退役直接 ensure_role("admin")。
|
||
BE-3 在 api/workflows.py 的发布、退役接口开头调。"""
|
||
ensure_role("admin" if level == "governed" else "builder")
|
||
|
||
|
||
def user_can_chat() -> bool:
|
||
"""开登录后 user 角色能不能用问数据(Q2,管理员在「设置 → 身份」里开)。"""
|
||
from app.auth import user_can_chat as allowed
|
||
|
||
return allowed()
|
||
|
||
|
||
async def ensure_conversation_access(session: Any, conversation_id: str | None) -> Any:
|
||
"""接受 conversation_id 的接口(助手的 generate-stream、generate、app-stream、from-run……)在读这段对话的历史、记来源
|
||
之前调:这段对话这个人能不能用(对话按人隔离,D-d)。能用交回对话(Conversation);没给 id、对话不存在交回 None
|
||
(和以前一样当没有历史,不报错);存在但不能用回 403。规则见 api/conversations.py 的同名函数。"""
|
||
from app.api.conversations import ensure_conversation_access as check
|
||
|
||
return await check(session, conversation_id)
|
||
|
||
|
||
def _forbidden() -> HTTPException:
|
||
return HTTPException(403, "没有权限查看这次运行")
|
||
|
||
|
||
#: user 角色在自己的问数据运行上能读的(GET /api/runs/{id} 之后的部分):运行记录、事件、运行时的图,加上实时流
|
||
#: (WebSocket /stream,握手时另判)。这几样就是问数据页要的;状态、工件列表、checkpoint 历史不给
|
||
_CHAT_RUN_READS = ("", "/events", "/graph")
|
||
_RUNS_FOR_BUILDERS = "没有权限:运行记录只对搭建者及以上的角色开放"
|
||
|
||
|
||
def masked_at_boundary(run: Any) -> bool:
|
||
"""这次运行的查询结果是不是发起时就定下在工具边界遮(tools/datasource.py 的 MASK_RESULTS_OPTION)。开登录后 user 角色
|
||
发起的问数据运行一定带它(api/runs.py 的 start_run),随运行记录沿用到续跑、审批恢复。"""
|
||
from app.tools.datasource import MASK_RESULTS_OPTION
|
||
|
||
options = getattr(run, "options", None)
|
||
return isinstance(options, dict) and bool(options.get(MASK_RESULTS_OPTION))
|
||
|
||
|
||
async def run_events_readable(principal: Principal, run: Any) -> bool:
|
||
"""能不能读这次运行的原始记录、事件和实时流(GET /api/runs/{id}、/events、/graph,WebSocket /stream)。
|
||
|
||
关登录、builder 及以上:能。user:这次运行它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。
|
||
两个条件缺一不可:没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的
|
||
应用实例、有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。实际上满足两条的就是它自己开登录后发起的
|
||
问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。"""
|
||
if principal.mode == "off" or principal.at_least("builder"):
|
||
return True
|
||
if run is None or not principal.username or not masked_at_boundary(run):
|
||
return False
|
||
return await can_read_run(principal, run)
|
||
|
||
|
||
def _own_chat_run(principal: Principal, run: Any) -> bool:
|
||
return run is not None and bool(principal.username) and run.started_by == principal.username \
|
||
and run.origin_kind == "chat"
|
||
|
||
|
||
async def _run_scope(conn: HTTPConnection) -> None:
|
||
"""/api/runs 下的接口和运行的实时流。builder 及以上全部放行;user 角色只在管理员开了问数据(Q2)时能碰这组接口:
|
||
- 列表、别人的运行一律不给(记录页是给搭建者看的);
|
||
- 能发起(只能是问数据的探索运行,start_run 里按请求体再查);能停止自己的问数据运行;
|
||
- 能读运行记录、事件、运行时的图,能连 WebSocket:按运行判(run_events_readable),握手时就判,不再一刀切只给
|
||
builder。能读的就是它自己发起、查询结果在工具边界遮过的问数据运行,事件、回答里只有遮过的值;
|
||
- 自己发起的应用实例、有授权的应用视图运行、参与的实例,经应用页(/api/apps/{id}/instances)和证据接口
|
||
(evidence_scope)读,这里不给原始事件和状态:tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到。"""
|
||
principal = current_principal()
|
||
if principal.mode == "off" or principal.at_least("builder"):
|
||
return
|
||
if conn.scope["type"] == "websocket":
|
||
# 握手前拒:服务端回 1008,不建立连接。没登录的中间件已经挡掉了,这里只剩按运行判
|
||
if not (principal.username and user_can_chat()
|
||
and await run_events_readable(principal, await _load_run(conn.path_params.get("run_id")))):
|
||
from fastapi import WebSocketException, status
|
||
|
||
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
|
||
return
|
||
ensure_role("user")
|
||
if not user_can_chat():
|
||
raise HTTPException(403, _RUNS_FOR_BUILDERS)
|
||
method = conn.scope.get("method", "GET")
|
||
run_id = conn.path_params.get("run_id")
|
||
if run_id is None:
|
||
if method == "POST" and conn.url.path.rstrip("/") == "/api/runs":
|
||
return
|
||
raise HTTPException(403, _RUNS_FOR_BUILDERS)
|
||
run = await _load_run(run_id)
|
||
rest = conn.url.path.rstrip("/").removeprefix(f"/api/runs/{run_id}")
|
||
if method == "GET" and rest in _CHAT_RUN_READS:
|
||
if await run_events_readable(principal, run):
|
||
return
|
||
raise _forbidden()
|
||
if not _own_chat_run(principal, run):
|
||
raise _forbidden()
|
||
if method == "POST" and rest == "/cancel":
|
||
return
|
||
raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色")
|
||
|
||
|
||
async def _load_run(run_id: str | None) -> Any:
|
||
if not run_id:
|
||
return None
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run
|
||
|
||
async with SessionLocal() as session:
|
||
return await session.get(Run, run_id)
|
||
|
||
|
||
def restricted() -> bool:
|
||
"""这次请求的人是不是开登录后的 user 角色:对它来说遮罩就是边界,只能做问数据、用应用这几件事。"""
|
||
principal = current_principal()
|
||
return principal.mode != "off" and not principal.at_least("builder")
|
||
|
||
|
||
async def _evidence_scope(conn: HTTPConnection) -> None:
|
||
"""/api/runs/{run_id}/evidence… 这组证据接口:builder 及以上全部放行;user 角色只读(GET),而且这次运行要能读
|
||
(can_read_run)。证据接口交出的查询行本身按派生遮罩处理过。"""
|
||
principal = current_principal()
|
||
if principal.mode == "off" or principal.at_least("builder") or conn.scope["type"] != "http":
|
||
return
|
||
ensure_role("user")
|
||
if conn.scope.get("method", "GET") != "GET":
|
||
raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色")
|
||
run_id = conn.path_params.get("run_id")
|
||
if not run_id or not await can_read_run(principal, run_id):
|
||
raise _forbidden()
|
||
|
||
|
||
async def _chat_scope(conn: HTTPConnection) -> None:
|
||
"""问数据和助手(对话、生成):builder 及以上;user 角色要管理员开了问数据(Q2),而且只给问数据用得到的那几个接口。
|
||
对话按人隔离(只看得到、只改得动自己建的)在 api/conversations.py 里按对话判(ensure_conversation_access)。"""
|
||
principal = current_principal()
|
||
if principal.mode == "off" or principal.at_least("builder"):
|
||
return
|
||
ensure_role("user")
|
||
path = conn.url.path.rstrip("/")
|
||
method = conn.scope.get("method", "GET")
|
||
if user_can_chat() and (path.startswith("/api/conversations") or (method, path) in _CHAT_ROUTES):
|
||
return
|
||
raise HTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据")
|
||
|
||
|
||
#: user 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单
|
||
#: (GET /api/datasources/names,只有名字、说明和种类,不含连接信息)
|
||
_CHAT_ROUTES = frozenset({("POST", "/api/copilot/generate-stream"), ("GET", "/api/copilot/model"),
|
||
("GET", "/api/datasources/names")})
|
||
|
||
run_scope = Depends(_run_scope)
|
||
evidence_scope = Depends(_evidence_scope)
|
||
chat_scope = Depends(_chat_scope)
|
||
|
||
|
||
def role_by_method(*, read: str, write: str) -> Any:
|
||
"""读(GET、HEAD)要 read,改动要 write 的路由级依赖:模型接入、运行默认值这类搭建者只读的设置。"""
|
||
|
||
async def _check(conn: HTTPConnection) -> None:
|
||
ensure_role(read if conn.scope.get("method", "GET") in ("GET", "HEAD") else write)
|
||
|
||
return Depends(_check)
|