mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:51:59 +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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
161 lines
8.2 KiB
Python
161 lines
8.2 KiB
Python
"""应用入口:按入口表单校验 → 映射成钉住版本的运行输入 → 发起正式运行(来源 app_entry / hook)。
|
||
|
||
应用里的运行永远是钉住版本的正式运行(§2.11):版本、graph_hash 都取应用当前发布版里记下的那一份,launch_run 再核对一遍
|
||
(expect_hash)。实例标题只能按名字引用入口字段,在这里拼好、记进 run.origin(在封存范围内)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.apps.tree import entry_of, pins_by_alias, resolve_form
|
||
from app.files import store as files_store
|
||
|
||
#: 入口发起被拒时的机器码
|
||
APP_GONE = "app_not_found"
|
||
APP_UNPUBLISHED = "app_unpublished"
|
||
ENTRY_GONE = "app_entry_not_found"
|
||
NEEDS_UPGRADE = "app_needs_upgrade"
|
||
|
||
_PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_㐀-鿿]+)\}")
|
||
TITLE_MAX = 200
|
||
|
||
|
||
class EntryRefused(Exception):
|
||
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
|
||
super().__init__(message)
|
||
self.status_code = status_code
|
||
self.message = message
|
||
self.code = code
|
||
|
||
|
||
class FormRejected(Exception):
|
||
def __init__(self, errors: list[dict[str, Any]]) -> None:
|
||
super().__init__("表单没有通过校验")
|
||
self.errors = errors
|
||
|
||
|
||
def map_inputs(entry: dict[str, Any], values: dict[str, Any], principal: Any) -> dict[str, Any]:
|
||
"""入口映射:入口字段 ← 表单值 / 当前用户 / 常量。表单里没填的不交(入口节点补默认值)。"""
|
||
from app.apps.lint import parse_map_source
|
||
|
||
out: dict[str, Any] = {}
|
||
for target, source in (entry.get("map") or {}).items():
|
||
parsed = parse_map_source(source)
|
||
if parsed is None:
|
||
continue
|
||
kind, what = parsed
|
||
if kind == "form":
|
||
value = values.get(what)
|
||
if value in (None, ""):
|
||
continue
|
||
elif kind == "user":
|
||
value = (getattr(principal, "display_name", None) or getattr(principal, "username", None)) \
|
||
if what == "display_name" else getattr(principal, "username", None)
|
||
if value is None:
|
||
continue
|
||
else:
|
||
value = what
|
||
out[target] = value
|
||
return out
|
||
|
||
|
||
def render_title(entry: dict[str, Any], inputs: dict[str, Any]) -> str | None:
|
||
"""实例标题:pattern 里的 {字段} 换成入口字段的值,只认 fields 里列出的名字;没写标题的为 None。"""
|
||
title = entry.get("instance_title")
|
||
if not isinstance(title, dict) or not isinstance(title.get("pattern"), str):
|
||
return None
|
||
allowed = set(title.get("fields") or [])
|
||
|
||
def sub(m: re.Match[str]) -> str:
|
||
name = m.group(1)
|
||
if name not in allowed:
|
||
return m.group(0)
|
||
value = inputs.get(name)
|
||
return "" if value is None else str(value)
|
||
|
||
return _PLACEHOLDER.sub(sub, title["pattern"]).strip()[:TITLE_MAX] or None
|
||
|
||
|
||
async def start_entry(app_id: str, entry_id: str, values: dict[str, Any], principal: Any, *,
|
||
origin_kind: str = "app_entry", hook: str | None = None, hook_key: str | None = None,
|
||
idempotency_key: str | None = None) -> Any:
|
||
"""发起一个实例,返回 Run。校验失败抛 FormRejected(errors);应用需升级(钉住的版本已退役)时拒绝。
|
||
|
||
钩子发起的(origin_kind=hook)来源里另记钩子名和这次请求的幂等键(hook_key):处理到一半服务停了、同一个键重新
|
||
认领时,按它找已经发起的实例(process/hooks.py),不重复发起。
|
||
用访问密钥发起的(docs/design/next-round/04-对外集成与触达.md A4)来源仍是 app_entry,另记 via=key、哪一把密钥、叫什么
|
||
(auth/keys.trace)和这次的幂等键(idempotency_key),同样在封存范围内:查得到是哪把密钥发起的,重新认领时按它找。
|
||
|
||
principal.username 记成发起人(关登录时是自报的署名,调用方按 X-Actor 填好)。发起被 launch_run 拒绝时抛
|
||
LaunchRefused,原样交给调用方。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.apps.forms import validate_form
|
||
from app.apps.pins import load_pins
|
||
from app.db.base import SessionLocal
|
||
from app.db.models_apps import App, AppVersion
|
||
from app.engine.launch import launch_run
|
||
|
||
async with SessionLocal() as session:
|
||
app = await session.get(App, app_id)
|
||
if app is None or app.status == "archived":
|
||
raise EntryRefused(404, "应用不存在或已归档", APP_GONE)
|
||
if not app.published_version:
|
||
raise EntryRefused(409, f"「{app.name}」还没有发布,发布之后才能发起", APP_UNPUBLISHED)
|
||
version = (await session.execute(select(AppVersion).where(
|
||
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one()
|
||
spec = version.spec or {}
|
||
entry = entry_of(spec, entry_id)
|
||
if entry is None:
|
||
raise EntryRefused(404, f"应用里没有入口「{entry_id}」", ENTRY_GONE)
|
||
pin = pins_by_alias(spec).get(str(entry.get("pin")))
|
||
state = (await load_pins(session, {"pins": [pin]})).get(str(entry.get("pin"))) if pin else None
|
||
if state is None or not state.usable:
|
||
raise EntryRefused(409, "这个入口固定的工作流版本已退役或不可用,应用需要升级后才能发起新实例", NEEDS_UPGRADE)
|
||
form = resolve_form(spec, entry.get("form")) or {"type": "object", "properties": {}}
|
||
if errors := validate_form(form, values if isinstance(values, dict) else None):
|
||
raise FormRejected(errors)
|
||
# 选人控件:开了登录时按候选池核对(auth/directory.py,05-已知遗留 (b))。以前原样传下去,填错的名字会把待办
|
||
# 派给一个不存在的人——没人能处理,也不报错
|
||
from app.auth.directory import principal_errors
|
||
|
||
if errors := await principal_errors(session, app, version.bindings, form, values,
|
||
getattr(principal, "mode", "off")):
|
||
raise FormRejected(errors)
|
||
# 表单里的文件(docs/design/normal-user-apps.md N9):核对是提交人在这个入口传的、还没用过,先占住;表单值换成
|
||
# 登记的那一份引用(连同 sha256 进运行输入、随封存固定)。发起成功绑到这次运行,发起不成放回去
|
||
values, claimed = await _claim_files(form, values, principal, app.id, entry_id)
|
||
inputs = map_inputs(entry, values, principal)
|
||
title = render_title(entry, inputs)
|
||
try:
|
||
run = await launch_run(
|
||
session, workflow_id=pin["workflow_id"], run_class="formal", version=pin["version"],
|
||
expect_hash=pin["graph_hash"], input_payload=inputs, actor=getattr(principal, "username", None),
|
||
origin_kind=origin_kind, app_id=app.id, app_version=version.version,
|
||
origin={"entry": entry_id, "spec_hash": version.spec_hash, "title": title, "hook": hook,
|
||
"hook_key": hook_key, "actor_verified": bool(getattr(principal, "verified", False)),
|
||
"auth_mode": getattr(principal, "mode", "off"), **_key_trace(principal),
|
||
**({"idempotency_key": idempotency_key} if idempotency_key else {})})
|
||
except BaseException:
|
||
await files_store.release(claimed)
|
||
raise
|
||
await files_store.finish(claimed, run.id)
|
||
return run
|
||
|
||
|
||
def _key_trace(principal: Any) -> dict[str, Any]:
|
||
from app.auth.keys import trace
|
||
|
||
return trace(principal)
|
||
|
||
|
||
async def _claim_files(form: dict[str, Any], values: dict[str, Any], principal: Any, app_id: str,
|
||
entry_id: str) -> tuple[dict[str, Any], Any]:
|
||
"""占住表单里的文件;引用不对(不是自己传的、在别处传的、已经用过)按表单错误交回,落到那个字段上。"""
|
||
try:
|
||
return await files_store.claim(form, values, principal=principal,
|
||
context={"app_id": app_id, "entry": entry_id})
|
||
except files_store.FileRefused as e:
|
||
raise FormRejected([{"path": e.field or "", "code": files_store.FILE_ERROR_CODE, "message": e.message}]) from e
|