Files
AgentLab/backend/app/apps/entries.py
T
ThearchyHelios 19329c873e 访问密钥与服务账号的后端(下一轮第 2 波 W2-d,细案 04 A1–A6、总方案 X8)
外部系统(门户、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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
2026-10-16 13:48:09 +08:00

161 lines
8.2 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.
"""应用入口:按入口表单校验 → 映射成钉住版本的运行输入 → 发起正式运行(来源 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