diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index a30dd51b..4a55b5c5 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.deps import Principal, actor_of, current_principal -from app.apps.access import app_role, at_least, grants_of +from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of from app.db.base import get_session from app.db.models import Run from app.db.models_apps import App, AppGrant, AppHook, AppVersion @@ -32,7 +32,8 @@ APP_GONE = "应用不存在,可能已被删除" NO_ACCESS = "app_forbidden" NOT_PUBLISHED = "app_unpublished" PUBLISH_CONFLICT = "app_publish_conflict" -_ROLE_TEXT = {"use": "使用", "observe": "查看全部实例", "manage": "管理"} +#: 归档了的应用不再改草稿、不再发布(没有恢复的接口:归档后这个应用只留着给进行中的实例走完、给人查) +APP_ARCHIVED = "app_archived" @router.get("/catalog") @@ -65,10 +66,15 @@ async def _app(session: AsyncSession, app_id: str, principal: Principal, need: s # 没有任何授权的人不该知道这个应用在不在 raise HTTPException(404, APP_GONE) if not at_least(role, need): - raise CodedHTTPException(403, f"需要这个应用的「{_ROLE_TEXT[need]}」权限", NO_ACCESS) + raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS) return app, role +def _refuse_archived(app: App, what: str) -> None: + if app.status == "archived": + raise CodedHTTPException(409, f"「{app.name}」已归档,不能再{what}", APP_ARCHIVED) + + def _require_builder(principal: Principal) -> None: if not principal.at_least("builder"): raise CodedHTTPException(403, "搭建应用需要搭建者权限", NO_ACCESS) @@ -248,6 +254,7 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep """改草稿:spec、岗位绑定、名称等。草稿随便改,发布时才过检查(形状不对的检查里报 app.format)。""" principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "manage") + _refuse_archived(app, "修改") if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)): raise HTTPException(422, problem) for key in ("name", "description", "icon"): @@ -266,7 +273,8 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep @router.post("/{app_id}/archive") async def archive_app(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: - """归档:不再出现在应用列表里,入口拒绝发起;版本、实例、生效记录都留着。""" + """归档:不再出现在应用列表里,入口拒绝发起、入站钩子一律拒收(apps/entries.py、process/hooks.py),不再改草稿、 + 不再发布;进行中的实例照常走完,版本、实例、生效记录都留着。没有恢复的接口。""" from app.apps.audit import record principal = _principal(x_actor) @@ -375,6 +383,7 @@ async def publish(app_id: str, payload: PublishIn, session: AsyncSession = Depen principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "manage") + _refuse_archived(app, "发布") previous = await _previous(session, app) draft = app.draft or {} issues = await lint_app(session, draft, app.draft_bindings or {}, previous=previous, @@ -465,8 +474,14 @@ async def get_grants(app_id: str, session: AsyncSession = Depends(get_session), @router.put("/{app_id}/grants") async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: - """整份替换授权。授权不进应用版本(改了不用重新发布),写审计。""" + """整份替换授权。授权不进应用版本(改了不用重新发布),写审计。 + + 给的人和组按名录核对(auth/directory.py,同选人控件、同应用检查):账号密码登录时名录里没有的、任何登录方式下停用了 + 的拒收,说清是哪一档、哪个名字——界面上选人时已经拦着,这里拦旧客户端和直接调接口的。单点登录的人第一次登录后才进 + 名录,名录里还没有的照收(检查里提醒);关登录不核对。""" + from app.api.deps import auth_mode from app.apps.audit import record + from app.auth.directory import load_directory, mismatch from app.db.models_apps import GRANT_ROLES app, _ = await _app(session, app_id, _principal(x_actor), "manage") @@ -480,6 +495,12 @@ async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Dep key = (item.principal_kind, principal, item.role) if key not in wanted: wanted.append(key) + if (mode := auth_mode()) != "off": + known = await load_directory(session) + refused = [f"「{ROLE_TEXT[role]}」里的{m.text}" for kind, who, role in wanted + if (m := mismatch(known, mode, kind, who)) is not None and m.blocking] + if refused: + raise HTTPException(422, f"{';'.join(refused)}:授权只能给名录里在用的人和组,请移除或从候选中重新选择") before = [_grant_out(g) for g in await grants_of(session, app.id)] for g in await grants_of(session, app.id): await session.delete(g) diff --git a/backend/app/api/copilot_gen.py b/backend/app/api/copilot_gen.py index 75f57d95..f33abc79 100644 --- a/backend/app/api/copilot_gen.py +++ b/backend/app/api/copilot_gen.py @@ -411,11 +411,12 @@ APP_REPAIR_PROMPT = ( ) REPAIR_LOOKS_PROMPT = "\n\n这一轮查看过的内容(和起草时看到的是同一份):\n{text}" -#: 应用检查里只有人能处理的问题:钉住哪个版本、升版怎么处置、岗位和核实人绑给谁、授权、通知渠道。不交回模型改, -#: 照常列在交付的问题里 +#: 应用检查里只有人能处理的问题:钉住哪个版本、升版怎么处置、岗位和核实人绑给谁(绑的人在不在名录里)、授权、通知 +#: 渠道。不交回模型改,照常列在交付的问题里 APP_HUMAN_ONLY = frozenset({ "app.pin_unpublished", "app.pin_retired", "app.pin_hash", "app.upgrade_policy", "app.slot_unbound", - "app.reconciler_unbound", "app.acl_empty", "app.notify_channel_missing", "app.process_unsafe", + "app.slot_principal_unknown", "app.reconciler_unbound", "app.acl_empty", "app.acl_principal_unknown", + "app.notify_channel_missing", "app.process_unsafe", "app.view_source_empty", "subgraph_version_unpublished", "subgraph_version_retired", }) diff --git a/backend/app/api/identity.py b/backend/app/api/identity.py index 75eadbe5..01759cd5 100644 --- a/backend/app/api/identity.py +++ b/backend/app/api/identity.py @@ -214,12 +214,14 @@ async def delete_group(group_id: str, session: AsyncSession = Depends(get_sessio @directory_router.get("/directory") async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - """选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。""" - users = (await session.execute(select(User).order_by(User.username))).scalars() - groups = (await session.execute(select(Group).order_by(Group.name))).scalars() - return {"users": [{"username": u.username, "display_name": u.display_name or u.username, "disabled": u.disabled} - for u in users], - "groups": [{"name": g.name, "label": g.label or g.name} for g in groups]} + """选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。 + 和应用检查、改授权时的核对同一处取(auth/directory.py)。""" + from app.auth.directory import load_directory + + known = await load_directory(session) + return {"users": [{"username": u.username, "display_name": u.display_name, "disabled": u.disabled} + for u in known.users.values()], + "groups": [{"name": g.name, "label": g.label} for g in known.groups.values()]} # ---------------------------------------------------------------- 审计 diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index ac7eb2fa..13efb265 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -14,7 +14,7 @@ from app.capabilities import secrets as vault from app.capabilities.endpoints import bound_endpoint_ref, find_endpoint from app.core.errors import explain, raw from app.db.base import get_session -from app.db.models import CustomTool, Endpoint, McpServer +from app.db.models import Capability, CustomTool, Endpoint, McpServer from app.tools.custom import ToolFailure, held_write, http_failure, schema_problem, trial from app.tools.mcp_manager import mcp_manager from app.tools.registry import ( @@ -72,6 +72,15 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ for t in await mcp_manager.list_tools()) except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉 pass + # 在工具库里执行是不是只演练(写外部系统的能力,和执行接口 _held 同一个判断):详情区的提示条照这个说「这里只演练, + # 不真的发送」,不说「请求会实际发出」 + customs = {row.name: row for row in (await session.execute(select(CustomTool))).scalars()} + # 能力登记一次读进会话:_held 逐个 session.get,登记里有的直接从会话里取,不再一个工具查一次库(会话按弱引用记着 + # 读过的行,列表留到循环结束) + _caps = (await session.execute(select(Capability))).scalars().all() + for item in out: + custom = customs.get(item["id"]) if item["source"] == "custom" else None + item["write_rehearsal"] = await _held(item["id"], custom, session) return out diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 801b6e99..f68c6fb4 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.core.errors import graph_error from app.api.coded import CodedHTTPException @@ -66,6 +67,9 @@ class WorkflowOut(BaseModel): published_by: str | None = None #: 当前发布版本的级别(published / governed)。status 在画布上改过之后会回到 draft,「这是不是受管工作流」看这一项 published_level: str | None = None + #: 画布这一版的图和当前发布版的图内容相同(按 graph_hash 比):撤回改动后保存出来的新版本号更大,内容却和发布版 + #: 一样,页头照实写「与发布版内容相同」,不写「领先 N 版」。没发布过、老数据没记哈希的为 false + same_as_published: bool = False created_at: Any = None updated_at: Any = None run_count: int = 0 @@ -82,6 +86,17 @@ async def _published_levels(session: AsyncSession, ids: list[str] | None = None) return {wid: level for wid, level in (await session.execute(stmt)).all() if level} +async def _same_as_published(session: AsyncSession, ids: list[str] | None = None) -> set[str]: + """画布这一版(Workflow.version)的图哈希和当前发布版相同的工作流 id。两边哈希都记着才比,不在前端比图。""" + current, published = aliased(WorkflowVersion), aliased(WorkflowVersion) + stmt = select(Workflow.id, current.graph_hash, published.graph_hash).join( + current, (current.workflow_id == Workflow.id) & (current.version == Workflow.version)).join( + published, (published.workflow_id == Workflow.id) & (published.version == Workflow.published_version)) + if ids is not None: + stmt = stmt.where(Workflow.id.in_(ids)) + return {wid for wid, mine, theirs in (await session.execute(stmt)).all() if mine and mine == theirs} + + async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]: """各工作流的运行次数 {工作流 id: 次数},ids 为 None 时算全部。列表和单个工作流的接口都按它算:以前只有列表 计数,详情、保存、恢复的返回落在 WorkflowOut 的默认值 0 上,同一个工作流两处说的次数不一样。""" @@ -96,6 +111,7 @@ async def _workflow_out(session: AsyncSession, workflow: Workflow) -> WorkflowOu item = WorkflowOut.model_validate(workflow) item.run_count = (await _run_counts(session, [workflow.id])).get(workflow.id, 0) item.published_level = (await _published_levels(session, [workflow.id])).get(workflow.id) + item.same_as_published = workflow.id in await _same_as_published(session, [workflow.id]) return item @@ -106,11 +122,13 @@ async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[W ) counts = await _run_counts(session) levels = await _published_levels(session) + same = await _same_as_published(session) out: list[WorkflowOut] = [] for row in rows: item = WorkflowOut.model_validate(row) item.run_count = counts.get(row.id, 0) item.published_level = levels.get(row.id) + item.same_as_published = row.id in same out.append(item) return out diff --git a/backend/app/apps/access.py b/backend/app/apps/access.py index 0ddbc778..a0ce020e 100644 --- a/backend/app/apps/access.py +++ b/backend/app/apps/access.py @@ -17,6 +17,8 @@ from app.api.deps import Principal from app.db.models_apps import AppGrant RANK = {"use": 1, "observe": 2, "manage": 3} +#: 三档授权的叫法(同前端 GRANT_ROLE_LABEL):权限不够的报错、授权核对的说明里写这个 +ROLE_TEXT = {"use": "使用", "observe": "查看全部实例", "manage": "管理"} def at_least(role: str | None, need: str) -> bool: diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index b4030f0d..98f13295 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -553,6 +553,7 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[ label = (info or {}).get("label") or slot why = f"({';'.join(used[slot][:3])}):没有人,流程走到这一步就派给了任何人" if used.get(slot) else "" out.error("app.slot_unbound", f"岗位「{label}」还没有绑定到人或组{why}", f"slots.{slot}") + await _lint_slot_principals(session, slots, bound, out) for state in process: nodes = node_map(state.graph) for node_id, cap in writes[state.alias].items(): @@ -572,6 +573,33 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[ f"pins[{_pin_index(spec, state.alias)}]", node_id=issue.node_id) +async def _lint_slot_principals(session: AsyncSession, slots: dict[str, Any], bound: dict[str, Any], + out: _Issues) -> None: + """岗位绑定的人和组要在名录里(auth/directory.py,同选人控件的名录):绑了名录里没有的、已停用的,流程走到这一步就 + 派给了没人能处理的名字——界面上选人时拦着,旧客户端、直接调接口、绑好之后人被停用或删除的照样会漏过来,所以发布时 + 再核一次。账号密码登录报错;单点登录的人第一次登录后才进名录,名录里还没有的只提醒;关登录不核对。""" + from app.api.deps import auth_mode + from app.auth.directory import load_directory, mismatch + + mode = auth_mode() + if mode == "off" or not any(bound.get(s) for s in slots): + return + known = await load_directory(session) + for slot, info in slots.items(): + label = (info or {}).get("label") or slot + for item in bound.get(slot) or []: + m = mismatch(known, mode, item.get("kind"), str(item.get("name"))) if isinstance(item, dict) else None + if m is None: + continue + if m.blocking: + out.error("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}:派给这个名字的待办没有人能处理" + "(全体会签时整条待办办不完)。请从候选中重新选择,或从岗位里移除", f"slots.{slot}", + problem=m.problem) + else: + out.warning("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}", f"slots.{slot}", + problem=m.problem) + + def _pin_index(spec: dict[str, Any], alias: str) -> int: return next((i for i, p in enumerate(spec.get("pins") or []) if p.get("alias") == alias), 0) @@ -597,17 +625,37 @@ async def _lint_notify(session: AsyncSession, spec: dict[str, Any], out: _Issues async def acl_issues(session: AsyncSession, app_id: str) -> list[dict[str, Any]]: - """开了登录却没有任何 use 以上的授权:除了管理员和搭建者,谁都用不了这个应用。关登录时不报。""" - from app.api.deps import auth_mode - from app.db.models_apps import AppGrant + """开了登录却没有任何 use 以上的授权:除了管理员和搭建者,谁都用不了这个应用。关登录时不报。 - if auth_mode() == "off": + 授权给的人和组要在名录里(同岗位绑定,见 _lint_slot_principals):授给名录里没有的名字,以后建了同名的人就直接拿到 + 这一档权限;授给停用的人,重新启用就又能用——都说清是哪一档、哪个名字。账号密码登录报错,单点登录名录里还没有的只提醒。""" + from app.api.deps import auth_mode + from app.apps.access import ROLE_TEXT, grants_of + from app.auth.directory import load_directory, mismatch + + mode = auth_mode() + if mode == "off": return [] - count = len((await session.execute(select(AppGrant.id).where(AppGrant.app_id == app_id))).all()) - if count: - return [] - return [{"message": "还没有授权给任何人或组使用这个应用:只有管理员和搭建者能打开它", "level": "warning", - "code": "app.acl_empty", "path": None}] + grants = await grants_of(session, app_id) + if not grants: + return [{"message": "还没有授权给任何人或组使用这个应用:只有管理员和搭建者能打开它", "level": "warning", + "code": "app.acl_empty", "path": None}] + out = _Issues() + known = await load_directory(session) + for g in grants: + if (m := mismatch(known, mode, g.principal_kind, g.principal)) is None: + continue + what = f"「{ROLE_TEXT.get(g.role, g.role)}」授权给的{m.text}" + if m.blocking: + out.error("app.acl_principal_unknown", f"{what}:{_GRANT_RISK[m.problem]}", "grants", problem=m.problem) + else: + out.warning("app.acl_principal_unknown", what, "grants", problem=m.problem) + return out.items + + +#: 授权给了名录里对不上的名字,会出什么事 +_GRANT_RISK = {"missing": "以后建了同名的人会直接拿到这一档权限。请从授权里移除,或从候选中重新选择", + "disabled": "重新启用后他又能用这个应用。请从授权里移除"} def pins_with_state(spec: dict[str, Any], pins: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 14f0d645..652dbfe3 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -405,6 +405,8 @@ class _Page: params: dict[str, Any] draft: bool actor: str | None + #: 正在解析的这一页(组件之间要互相知道的:同一页上有没有绑同一条待办的会签组件) + page: dict[str, Any] = field(default_factory=dict) pins: dict[str, PinState] = field(default_factory=dict) sources: dict[str, Source] = field(default_factory=dict) problems: list[dict[str, Any]] = field(default_factory=list) @@ -454,7 +456,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip if page is None: raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found") ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role, - instance=instance, params=params or {}, draft=draft, actor=actor) + instance=instance, params=params or {}, draft=draft, actor=actor, page=page) ctx.pins = await load_pins(session, spec) upgrade = needs_upgrade(ctx.pins) level = version.level if (version is not None and not draft) else app_level( @@ -555,7 +557,7 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return Source(id=vid, kind=kind, state="empty", pin=state, message="按需运行的视图:点「刷新」后按当前参数运行一次") if run.status in _ACTIVE: - return Source(id=vid, kind=kind, state="running", run=run, pin=state, message="正在运行,完成后刷新页面") + return Source(id=vid, kind=kind, state="running", run=run, pin=state, message=await _active_text(session, run)) source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state, origin=src.get("from")) if run.status != "succeeded": @@ -563,6 +565,19 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return source +#: 来源的运行还没结束时各块写什么。页面在跟着(应用页轮询、实例页轮询或接事件流),不用人去刷新 +RUNNING_TEXT = "正在运行,完成后自动刷新" + + +async def _active_text(session: AsyncSession, run: Run) -> str: + """来源的运行还没结束:在跑写「正在运行」;停着等人(interrupted)按在等的那一步说等什么(等待审批、等待确认发送、 + 等待核实写入结果……,同实例的状态 instance_state),不说「正在运行」——页头写着「等待审批」,这里不能对不上。""" + if run.status != "interrupted": + return RUNNING_TEXT + text = instance_state(run, (await _trails(session, [run]))[run.id])["text"] + return f"{text},流程走完后自动显示" + + async def _empty_text(session: AsyncSession, state: PinState, origin: Any) -> str: """最近封存运行的视图还没有数据时说什么:还没有、什么时候会有(定时任务的下次运行时间,或者要等一次正式运行)。""" if isinstance(origin, str) and origin.startswith("schedule:"): @@ -1095,12 +1110,42 @@ def _decision(trail: _Trail, node_id: str) -> tuple[str | None, str | None]: return done[-1].kind, (done[-1].outcome or {}).get("decision") +#: 人工节点的「驳回」出口(engine/layout.py 的 approved / rejected) +REJECTED_HANDLE = "rejected" +_REJECTIONS = ("reject", "expired_reject") + + +def rejected_outputs(graph: Any) -> dict[str, set[str]]: + """成果节点 → 从哪几个人工节点的「驳回」出口一路走到它(中间不经过别的人工节点:再遇到人工节点,结局由那一个人 + 的决定说了算)。按图认,不按成果节点的标题猜。""" + from app.apps.tree import node_map + + nodes = node_map(graph) + edges = [e for e in (graph or {}).get("edges") or [] if isinstance(e, dict)] + out: dict[str, set[str]] = {} + for gate in (e for e in edges if e.get("sourceHandle") == REJECTED_HANDLE + and (nodes.get(str(e.get("source"))) or {}).get("type") == "human"): + seen: set[str] = set() + todo = [str(gate.get("target"))] + while todo: + nid = todo.pop() + if nid in seen or nid not in nodes or nodes[nid]["type"] == "human": + continue + seen.add(nid) + if nodes[nid]["type"] == "output": + out.setdefault(nid, set()).add(str(gate["source"])) + todo += [str(e.get("target")) for e in edges if str(e.get("source")) == nid] + return out + + def instance_state(run: Run, trail: _Trail) -> dict[str, str]: """实例给人看的状态 {code, text}:code 是界面画状态标记用的状态(运行的那几种,加 held),text 说这件事办到哪了、 结论是什么,不只是运行状态(P1)。 - 走完了:图里有几个成果节点(分支的几种结局,如「已通过」「已退回」)、恰好走到了其中一个的,写走到的那一个;只有 - 一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局; + 一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局。走到的是 + 否定的那种结局——这次运行里人工节点驳回了、从它的「驳回」出口一路走到的成果节点(rejected_outputs)——code 记 + cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题; - 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……); - 失败:停在一个被人驳回的待办上的——动作确认被驳回(「付款」未确认(被驳回))、审批设了驳回即终止、补充输入被 驳回——是人的决定,不是故障,code 记 cancelled;核实为未生效、选了放弃的同理。外部系统拒绝之类照写失败。 @@ -1124,7 +1169,9 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: outputs = {nid for nid, n in nodes.items() if n["type"] == "output"} reached = [nid for etype, nid in trail.events if etype == "node.finished" and nid in outputs] if len(outputs) > 1 and len(set(reached)) == 1: - return {"code": "succeeded", "text": title(reached[0])} + gates = rejected_outputs(run.graph).get(reached[0], set()) + negative = any(_decision(trail, gate)[1] in _REJECTIONS for gate in gates) + return {"code": "cancelled" if negative else "succeeded", "text": title(reached[0])} return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]} if run.status == "failed": failed = [nid for etype, nid in trail.events if etype == "node.failed"] @@ -1133,7 +1180,7 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: kind, decision = _decision(trail, node_id) if kind == "action_confirm" and decision == "reject": return {"code": "cancelled", "text": f"「{title(node_id)}」未确认(被驳回)"} - if decision in ("reject", "expired_reject"): + if decision in _REJECTIONS: return {"code": "cancelled", "text": f"「{title(node_id)}」被驳回"} record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None) if record is not None and record.state == "failed": @@ -1241,7 +1288,8 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]: text = WAITING_TEXT.get(pending[-1].kind) elif step["state"] == "failed" and kind == "action_confirm" and decision == "reject": step["state"], text = "rejected", "未确认(被驳回)" - elif step["state"] == "failed" and decision in ("reject", "expired_reject"): + elif step["state"] in ("failed", "done") and decision in _REJECTIONS: + # 驳回了:驳回即终止的(节点失败)和从「驳回」出口接着走的(节点完成)都是人的决定,不画成成功 step["state"], text = "rejected", _DECISION_TEXT[decision] elif step["state"] == "failed" and record is not None and (record.resolution or {}).get("outcome") == "abandon": step["state"], text = "rejected", "核实为未生效,已放弃发送" @@ -1324,9 +1372,14 @@ async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str """待办(task_form、signoffs)、动作(action_status)。 待办这里就判定读者是不是这条待办的处理人(process.tasks.handles):是的,界面按 id 取完整的待办画待办卡(能处理的 - 给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。""" + 给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。 + + 同一页上另有绑同一条待办的会签组件(signoffs)时,待办表单不再画会签进度(signoffs_on_page):同一页上「会签进度 + 0/2」不写两遍。人名给显示名(people:用户名 → 显示名),界面写显示名、悬停给用户名。""" + from app.apps.tree import components + from app.auth.directory import display_names from app.db.models_process import ActionRecord, Task, TaskAssignment - from app.process.tasks import handles, my_response, principal_with_actor, progress + from app.process.tasks import handles, my_response, people_of, principal_with_actor, progress run = ctx.instance if run is None: @@ -1340,13 +1393,18 @@ async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str .order_by(TaskAssignment.created_at))).scalars()) reader = principal_with_actor(ctx.principal, ctx.actor) resolved_at = _aware(task.resolved_at).isoformat() if task.resolved_at else None + signoffs = ctype == "task_form" and any( + c.get("type") == "signoffs" and isinstance(c.get("bind"), dict) and c["bind"].get("task") == node_id + for c, _, _ in components({"pages": [ctx.page]})) return {"state": "ok", "task": {**_task_row(task, instance_title(run)), "form": task.form, "resolved_by": task.resolved_by, "resolved_at": resolved_at, "outcome": {"decision": (task.outcome or {}).get("decision")} if task.outcome else None}, "task_ui": (ctx.spec.get("task_ui") or {}).get(node_id) or {}, "handler": handles(rows, reader), "assignments": [_assignment_row(a) for a in rows], - "progress": progress(task, rows), "my_response": my_response(rows, reader)} + "progress": progress(task, rows), "my_response": my_response(rows, reader), + "people": await display_names(session, people_of(task, rows)), + **({"signoffs_on_page": True} if signoffs else {})} records = (await session.execute(select(ActionRecord).where(ActionRecord.run_id == run.id, ActionRecord.node_id == node_id) .order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars() diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py new file mode 100644 index 00000000..6f689ec1 --- /dev/null +++ b/backend/app/auth/directory.py @@ -0,0 +1,97 @@ +"""人和组的名录:选人控件的候选(GET /api/directory)、应用检查核对岗位绑定和授权、改授权时核对填的名字,同一处取、 +同一个口径(停用的人也在名录里,标 disabled:已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」)。 + +名录里对不上怎么算看登录方式(同前端 PrincipalPicker): +- 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错; +- 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错; +- 关登录:没有用户表,签批记的是各自填的署名,不核对。 +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +#: 名录里对不上的两种:没有这个人或组、人停用了 +Problem = Literal["missing", "disabled"] +_KIND = {"user": "用户", "group": "组"} + + +@dataclass(frozen=True) +class DirectoryUser: + username: str + display_name: str + disabled: bool + + +@dataclass(frozen=True) +class DirectoryGroup: + name: str + label: str + + +@dataclass +class Directory: + users: dict[str, DirectoryUser] = field(default_factory=dict) + groups: dict[str, DirectoryGroup] = field(default_factory=dict) + + def problem(self, kind: str, name: str) -> Problem | None: + """这一项在名录里对不上的地方;所有人(everyone)不判。""" + if kind == "group": + return None if name in self.groups else "missing" + if kind == "user": + user = self.users.get(name) + return "missing" if user is None else "disabled" if user.disabled else None + return None + + +async def display_names(session: AsyncSession, usernames: Iterable[str | None]) -> dict[str, str]: + """用户名 → 显示名(名录里有的;没写显示名的就是用户名)。待办上派给谁、谁回复的、谁处理的给人看时写显示名,悬停给 + 用户名。关登录时没有用户表,查不到的照写署名(调用方 people.get(名字, 名字))。""" + from app.db.models import User + + wanted = sorted({u for u in usernames if u}) + if not wanted: + return {} + rows = (await session.execute(select(User.username, User.display_name).where(User.username.in_(wanted)))).all() + return {username: display or username for username, display in rows} + + +async def load_directory(session: AsyncSession) -> Directory: + from app.db.models import Group, User + + users = (await session.execute(select(User).order_by(User.username))).scalars() + groups = (await session.execute(select(Group).order_by(Group.name))).scalars() + return Directory( + users={u.username: DirectoryUser(u.username, u.display_name or u.username, u.disabled) for u in users}, + groups={g.name: DirectoryGroup(g.name, g.label or g.name) for g in groups}) + + +@dataclass(frozen=True) +class Mismatch: + """一项对不上:blocking 为真是错(账号密码登录名录里没有、任何登录方式下停用了),为假只提醒(单点登录名录里还没有)。 + text 是「用户「x」已停用」这样的半句,调用方接上是哪个岗位、哪一档授权。""" + kind: str + name: str + problem: Problem + blocking: bool + text: str + + +def mismatch(directory: Directory, mode: str, kind: str, name: str) -> Mismatch | None: + """按登录方式判这一项:关登录、所有人、对得上的都是 None。""" + if mode == "off": + return None + problem = directory.problem(kind, name) + if problem is None: + return None + what = f"{_KIND.get(kind, kind)}「{name}」" + if problem == "disabled": + return Mismatch(kind, name, problem, True, f"{what}已停用") + if mode == "oidc": + return Mismatch(kind, name, problem, False, + f"{what}名录里还没有:单点登录的人和组第一次登录后才进名录,名字要和身份提供方里的一致才生效") + return Mismatch(kind, name, problem, True, f"{what}名录里没有") diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 78f125f7..d0726872 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -22,6 +22,7 @@ from typing import Any from sqlalchemy import and_, exists, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.directory import display_names from app.core.events import EventType from app.db.base import SessionLocal, utcnow from app.db.models import ( @@ -909,6 +910,7 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> .where(Run.id.in_({t.run_id for t in tasks})))).all()} apps = dict((await session.execute(select(App.id, App.name).where( App.id.in_({t.app_id for t in tasks if t.app_id})))).all()) + names = await display_names(session, [u for t in tasks for u in people_of(t, assignments.get(t.id, []))]) out = [] for task in tasks: rows = assignments.get(task.id, []) @@ -936,10 +938,18 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> "my_response": my_response(rows, principal), "outcome": task.outcome, "resolved_by": task.resolved_by, "resolved_at": _iso(task.resolved_at), "created_at": _iso(task.created_at), + # 这条待办上出现的人的显示名(派给谁、谁回复、谁处理、升级给谁):界面写显示名,悬停给用户名 + "people": {u: names[u] for u in people_of(task, rows) if u in names}, }) return out +def people_of(task: Task, rows: list[TaskAssignment]) -> set[str]: + """待办上按用户名出现的人:派给的人、回复的人、处理的人。""" + return ({a.principal for a in rows if a.principal_kind == "user"} | {a.responded_by for a in rows if a.responded_by} + | ({task.resolved_by} if task.resolved_by else set())) + + def visible(item: dict[str, Any], principal: Any) -> bool: """这条待办(_describe 的形状)这个人看不看得到:关登录、builder 以上都看得到;user 角色只看派给自己(或自己的组)的, 撤回了的不算。待办里带着中断内容(报告概况、动作预览),不能谁都能按 id 读。""" diff --git a/backend/tests/test_app_instance_page.py b/backend/tests/test_app_instance_page.py index 83112a8f..764273a0 100644 --- a/backend/tests/test_app_instance_page.py +++ b/backend/tests/test_app_instance_page.py @@ -5,9 +5,13 @@ 不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取; - 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项; - 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」; -- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点; +- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点;走到的是从人工节点「驳回」出口一路走到的 + 成果节点(否定的结局)时不画成成功的绿勾,时间线上「已驳回」那一步同样; - 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列; -- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间。 +- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间; +- 停着等审批时印章那一块写「正在运行,完成后刷新页面」,和页头的「等待审批」「自动刷新」对不上:按在等的那一步说; +- 「会签进度 0/2」画了两遍(待办表单一遍、会签组件一遍):同一页上有绑同一条待办的会签组件时,服务端告诉待办表单不画; +- 等待名单、会签名单写的是用户名:服务端给显示名(people),界面写显示名、悬停给用户名。 """ from __future__ import annotations @@ -49,11 +53,13 @@ def expense() -> dict: return {"nodes": nodes, "edges": edges} -SPEC = {"format": "agentlab.app/1", "name": "报销", "pages": [{"id": "instance", "title": "实例", "body": [ - {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, - {"id": "form", "type": "task_form", "bind": {"task": "review"}}, - {"id": "signs", "type": "signoffs", "bind": {"task": "review"}}, - {"id": "mine", "type": "task_list", "props": {"scope": "mine"}}]}]} +SPEC = {"format": "agentlab.app/1", "name": "报销", "views": {"inst": {"source": {"kind": "instance"}}}, + "pages": [{"id": "instance", "title": "实例", "body": [ + {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, + {"id": "form", "type": "task_form", "bind": {"task": "review"}}, + {"id": "signs", "type": "signoffs", "bind": {"task": "review"}}, + {"id": "mine", "type": "task_list", "props": {"scope": "mine"}}, + {"id": "st", "type": "stamp", "bind": {"view": "inst"}}]}]} def user(name: str) -> Principal: @@ -76,7 +82,7 @@ async def _setup() -> tuple[str, str, str, str]: u = uuid.uuid4().hex[:6] a, b, boss, group = f"zhang{u}", f"wang{u}", f"zhao{u}", f"finance{u}" async with SessionLocal() as session: - people = [User(username=n, role="user") for n in (a, b)] + people = [User(username=n, role="user", display_name=f"财务{n[:1]}") for n in (a, b)] session.add_all(people) g = Group(name=group) session.add(g) @@ -137,6 +143,14 @@ async def test_the_initiator_sees_the_step_not_a_missing_task(): hdr = view["hdr"] assert (hdr["status_code"], hdr["status_text"]) == ("waiting", "等待审批") assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "等待审批" + # 停着等审批:印章那一块按在等的那一步说,不写「正在运行,完成后刷新页面」(页头写着等待审批、自动刷新) + assert view["st"]["state"] == "running" and view["st"]["message"] == "等待审批,流程走完后自动显示" + # 同一页上有绑同一条待办的会签组件:待办表单不再画会签进度,会签组件照画 + assert form["signoffs_on_page"] is True and "signoffs_on_page" not in view["signs"] + # 等待名单写显示名:服务端给用户名 → 显示名,界面悬停给用户名 + assert form["people"] == {a: "财务z", b: "财务w"} and view["signs"]["people"] == form["people"] + detail = await tasks.detail(task.id, user(a)) + assert detail["people"] == {a: "财务z", b: "财务w"}, "待办卡(待办服务)同一个口径" async def test_signoffs_say_who_verified_and_when_and_the_voter_sees_their_own_answer(): @@ -176,9 +190,12 @@ async def test_the_instance_says_which_outcome_it_reached(): await tasks.respond((await _task(back)).id, user(b), decision="reject", note="票据不全") await _wait(back, ("succeeded",)) view = await _page(app_id, back, user("qianqi")) - # 走了退回分支:运行是「已完成」,流程的结论是「已退回」 - assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已退回") - assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "已驳回" + # 走了退回分支:运行是「已完成」,流程的结论是「已退回」。这是否定的结局(从「驳回」出口走到的成果节点),状态 + # 标记同「未确认(被驳回)」,不画成成功;时间线上驳回的那一步同样 + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "已退回") + assert view["tl"]["status_code"] == "cancelled" + review = next(s for s in view["tl"]["steps"] if s["node"] == "review") + assert (review["state"], review["text"]) == ("rejected", "已驳回") # 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 1 assert view["signs"]["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 1} from app.apps.resolve import list_instances @@ -186,6 +203,38 @@ async def test_the_instance_says_which_outcome_it_reached(): async with SessionLocal() as session: rows = {r["run_id"]: r for r in await list_instances(session, app_id, mine="qianqi")} assert (rows[back]["status_text"], rows[passed]["status_text"]) == ("已退回", "已通过") + assert (rows[back]["status_code"], rows[passed]["status_code"]) == ("cancelled", "succeeded") + + +def test_a_negative_outcome_is_read_from_the_graph_and_this_runs_decision(): + """否定的结局按图认(人工节点「驳回」出口一路走到的成果节点,中间不经过别的人工节点),再看这次运行里那个人工节点 + 是不是真的驳回了:两条路汇到同一个成果节点时,批准走过来的不算否定。不按成果节点的标题猜。""" + from types import SimpleNamespace + + from app.apps.resolve import _Trail, instance_state, rejected_outputs + + graph = {"nodes": [node("start", "input", "发起"), node("review", "human", "财务会签", mode="approve"), + node("pay", "tool", "付款"), node("note", "tool", "记一笔"), + node("done", "output", "办结"), node("back", "output", "打回重填"), + node("again", "human", "复核", mode="approve"), node("ok", "output", "复核通过")], + "edges": [{"source": "start", "target": "review"}, + {"source": "review", "target": "pay", "sourceHandle": "approved"}, + {"source": "review", "target": "note", "sourceHandle": "rejected"}, + {"source": "pay", "target": "done"}, {"source": "note", "target": "done"}, + {"source": "note", "target": "back"}, + {"source": "review", "target": "again", "sourceHandle": "rejected"}, + {"source": "again", "target": "ok", "sourceHandle": "approved"}]} + # 「复核通过」在另一个人工节点之后:结局由复核的人说了算,不算 review 驳回的结局 + assert rejected_outputs(graph) == {"done": {"review"}, "back": {"review"}} + + def state(decision, reached): + task = SimpleNamespace(node_id="review", kind="approve", status="resolved", outcome={"decision": decision}) + trail = _Trail(tasks=[task], events=[("node.finished", reached)]) + return instance_state(SimpleNamespace(status="succeeded", graph=graph), trail) + + assert state("reject", "back") == {"code": "cancelled", "text": "打回重填"} + assert state("expired_reject", "done") == {"code": "cancelled", "text": "办结"} + assert state("approve", "done") == {"code": "succeeded", "text": "办结"}, "批准走过来的同一个成果节点不算否定" async def test_outputs_reached_in_parallel_are_not_one_outcome(): diff --git a/backend/tests/test_app_principal_directory.py b/backend/tests/test_app_principal_directory.py new file mode 100644 index 00000000..0e4582d4 --- /dev/null +++ b/backend/tests/test_app_principal_directory.py @@ -0,0 +1,144 @@ +"""岗位绑定、应用授权里的人和组按名录核对(auth/directory.py,同 /api/directory、同前端 PrincipalPicker)。 + +界面上选人时拦着,旧客户端、直接调接口、绑好之后人被停用或删除的照样会漏过来: +- 账号密码登录:名录里没有的、停用了的,应用检查报错(说清是哪个岗位、哪一档授权、哪个名字),改授权回 422; +- 单点登录:人第一次登录后才进名录,名录里还没有的只提醒、照收;停用的照样算错; +- 关登录:不核对。 +""" +from __future__ import annotations + +import pytest +from sqlalchemy import delete + +from app.api import deps +from app.apps.derive import derive_app +from app.apps.lint import acl_issues, lint_app +from app.db.base import SessionLocal +from app.db.models import App, AppGrant, Group +from tests.fakes import auth_env +from tests.test_app_spec_tools import chain, make_workflow, node + + +def _approval(): + return chain( + node("start", "input", "开始", fields=[{"name": "amount", "type": "number", "required": True}]), + node("check", "human", "财务审批", mode="approve", assignee={"kind": "slot", "slot": "finance"}), + node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]), + ) + + +@pytest.fixture +async def people(): + """名录里:在用的 alice、停用的 dora、组 fin。""" + await auth_env.reset() + await auth_env.make_user("alice", "user") + await auth_env.make_user("dora", "user", disabled=True) + async with SessionLocal() as s: + s.add(Group(name="fin", label="财务", source="local")) + await s.commit() + try: + yield + finally: + async with SessionLocal() as s: + for model in (AppGrant, App): + await s.execute(delete(model)) + await s.commit() + await auth_env.reset() + + +def _mode(monkeypatch, mode: str) -> None: + monkeypatch.setattr(deps, "auth_mode", lambda: mode) + + +def _bound(*items): + return {"slots": {"finance": [{"kind": k, "name": n} for k, n in items]}} + + +def _principal_issues(issues, code="app.slot_principal_unknown"): + return [i for i in issues if i["code"] == code] + + +async def test_slot_bindings_are_checked_against_the_directory(people, monkeypatch): + wf, _ = await make_workflow(_approval()) + async with SessionLocal() as session: + spec = await derive_app(session, wf, 1) + spec["slots"]["finance"] = {"label": "财务审批人"} + + _mode(monkeypatch, "local") + assert not _principal_issues(await lint_app(session, spec, _bound(("user", "alice"), ("group", "fin")))) + issues = _principal_issues(await lint_app(session, spec, _bound( + ("user", "alice"), ("user", "wangwu"), ("group", "nogroup"), ("user", "dora")))) + assert [i["level"] for i in issues] == ["error"] * 3 + assert all(i["path"] == "slots.finance" for i in issues) + text = [i["message"] for i in issues] + assert "岗位「财务审批人」绑定的用户「wangwu」名录里没有" in text[0] + assert "组「nogroup」名录里没有" in text[1] + assert "用户「dora」已停用" in text[2] and issues[2]["problem"] == "disabled" + + # 单点登录:名录里还没有的只提醒(第一次登录后才进名录),停用的照样报错 + _mode(monkeypatch, "oidc") + issues = _principal_issues(await lint_app(session, spec, _bound(("user", "wangwu"), ("user", "dora")))) + assert [(i["level"], i["problem"]) for i in issues] == [("warning", "missing"), ("error", "disabled")] + assert "第一次登录后才进名录" in issues[0]["message"] + + # 关登录:没有用户表,不核对 + _mode(monkeypatch, "off") + assert not _principal_issues(await lint_app(session, spec, _bound(("user", "wangwu"), ("user", "dora")))) + + +async def test_grants_are_checked_against_the_directory(people, monkeypatch): + async with SessionLocal() as session: + app = App(name="授权核对") + session.add(app) + await session.flush() + session.add_all([AppGrant(app_id=app.id, principal_kind="user", principal="alice", role="use"), + AppGrant(app_id=app.id, principal_kind="everyone", principal="", role="use"), + AppGrant(app_id=app.id, principal_kind="user", principal="wangwu", role="observe"), + AppGrant(app_id=app.id, principal_kind="user", principal="dora", role="manage")]) + await session.commit() + + _mode(monkeypatch, "local") + issues = await acl_issues(session, app.id) + assert [(i["code"], i["level"], i["path"]) for i in issues] == [("app.acl_principal_unknown", "error", "grants")] * 2 + assert "「查看全部实例」授权给的用户「wangwu」名录里没有" in issues[0]["message"] + assert "以后建了同名的人会直接拿到这一档权限" in issues[0]["message"] + assert "「管理」授权给的用户「dora」已停用" in issues[1]["message"] + + _mode(monkeypatch, "oidc") + assert [i["level"] for i in await acl_issues(session, app.id)] == ["warning", "error"] + _mode(monkeypatch, "off") + assert await acl_issues(session, app.id) == [] + + +async def test_put_grants_refuses_names_not_in_the_directory(people, monkeypatch): + admin = await auth_env.turn_on_local() + app_id = (await admin.post("/api/apps", json={"name": "授权核对"})).json()["id"] + url = f"/api/apps/{app_id}/grants" + + r = await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "wangwu", "role": "use"}, + {"principal_kind": "user", "principal": "dora", "role": "observe"}]}) + assert r.status_code == 422 + assert "「使用」里的用户「wangwu」名录里没有" in r.json()["detail"] + assert "「查看全部实例」里的用户「dora」已停用" in r.json()["detail"] + assert (await admin.get(url)).json()["items"] == [], "拒收时一行都不改" + + ok = [{"principal_kind": "user", "principal": "alice", "role": "use"}, + {"principal_kind": "group", "principal": "fin", "role": "observe"}, + {"principal_kind": "everyone", "principal": "", "role": "use"}] + r = await admin.put(url, json={"items": ok}) + assert r.status_code == 200 and r.json()["items"] == ok + + # 单点登录:名录里还没有的照收(第一次登录后才进名录),停用的照样拒收 + _mode(monkeypatch, "oidc") + assert (await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "wangwu", "role": "use"}]}) + ).status_code == 200 + assert (await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "dora", "role": "use"}]}) + ).status_code == 422 + + +async def test_directory_endpoint_uses_the_same_source(people): + admin = await auth_env.turn_on_local() + body = (await admin.get("/api/directory")).json() + users = {u["username"]: u for u in body["users"]} + assert users["dora"]["disabled"] is True and users["alice"]["display_name"] == "alice" + assert body["groups"] == [{"name": "fin", "label": "财务"}] diff --git a/backend/tests/test_app_use.py b/backend/tests/test_app_use.py index 9843a6a1..24a4f083 100644 --- a/backend/tests/test_app_use.py +++ b/backend/tests/test_app_use.py @@ -181,6 +181,27 @@ async def test_entries_validate_map_and_title(client): assert home["forms"]["apply"]["properties"]["amount"]["title"] == "金额(元)" +async def test_an_archived_app_keeps_its_instances_but_takes_no_new_ones(client): + """归档(编辑器里的「归档」照这里写后果):应用列表不再列出,入口不再发起新实例;进行中、已有的实例照常能看; + 不再改草稿、不再发布(409 app_archived);没有恢复的接口。""" + wf, hash_ = await workflow(EXPENSE) + app_id = await published_app(client, expense_spec(wf, hash_), name=f"归档-{uuid.uuid4().hex[:6]}") + res = await client.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"amount": 1}}, headers=actor("张三")) + run_id = res.json()["run_id"] + archived = (await client.post(f"/api/apps/{app_id}/archive")).json() + assert archived["status"] == "archived" + assert app_id not in [a["id"] for a in (await client.get("/api/apps")).json()["items"]] + assert app_id in [a["id"] for a in (await client.get("/api/apps", params={"include_archived": True})).json()["items"]] + refused = await client.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"amount": 1}}) + assert refused.status_code == 404 and "已归档" in refused.json()["detail"] + assert (await client.get(f"/api/apps/{app_id}/instances/{run_id}")).status_code == 200, "已有的实例照常能看" + for res in (await client.patch(f"/api/apps/{app_id}", json={"name": "改名"}), + await client.post(f"/api/apps/{app_id}/publish", json={})): + assert res.status_code == 409 and res.json()["code"] == "app_archived", res.text + assert "不能再发布" in (await client.post(f"/api/apps/{app_id}/publish", json={})).json()["detail"] + assert (await client.get(f"/api/apps/{app_id}")).json()["status"] == "archived" + + async def test_an_app_on_a_retired_version_refuses_new_instances(client): wf, hash_ = await workflow(EXPENSE, versions=(1, 2), current=2) app_id = await published_app(client, expense_spec(wf, hash_)) diff --git a/backend/tests/test_app_versions.py b/backend/tests/test_app_versions.py index e6ae7a7d..a36765d9 100644 --- a/backend/tests/test_app_versions.py +++ b/backend/tests/test_app_versions.py @@ -400,3 +400,20 @@ async def test_only_an_admin_downgrades_a_governed_workflow(client, monkeypatch) plain = await workflow({1: "published", 2: None}, current=1, status="draft") ok = await client.post(f"/api/workflows/{plain}/publish", json={"level": "published", "version": 2}) assert ok.status_code == 200, ok.text + + +async def test_a_draft_equal_to_the_published_graph_says_so(client): + """撤回改动后保存出来的新版本号更大、内容却和发布版一样:服务端按 graph_hash 比好给标记(same_as_published), + 页头照实写「与发布版内容相同」,不写「领先 N 版」。详情、列表、保存的回包同一个口径。""" + created = (await client.post("/api/workflows", json={"name": f"撤回-{uuid.uuid4().hex[:6]}", "graph": simple()})).json() + wf = created["id"] + assert created["same_as_published"] is False, "没发布过不算相同" + assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"})).status_code == 200 + assert (await client.get(f"/api/workflows/{wf}")).json()["same_as_published"] is True + changed = (await client.patch(f"/api/workflows/{wf}", json={"graph": simple("改过")})).json() + assert changed["version"] == 2 and changed["same_as_published"] is False + # 视口不算内容(graph_hash 剔除 viewport) + back = (await client.patch(f"/api/workflows/{wf}", json={"graph": {**simple(), "viewport": {"x": 3, "y": 4, "zoom": 1}}})).json() + assert back["version"] == 3 and back["published_version"] == 1 and back["same_as_published"] is True + listed = next(w for w in (await client.get("/api/workflows")).json() if w["id"] == wf) + assert listed["same_as_published"] is True diff --git a/backend/tests/test_auth_access.py b/backend/tests/test_auth_access.py index 3b04aa44..0ecb431e 100644 --- a/backend/tests/test_auth_access.py +++ b/backend/tests/test_auth_access.py @@ -13,7 +13,7 @@ from __future__ import annotations import pytest -from sqlalchemy import delete, select +from sqlalchemy import delete from app.api.deps import Principal, can_read_artifact, can_read_run from app.core.artifact_store import put_json @@ -72,10 +72,12 @@ async def world(): s.add(DataSource(name="crm", kind="sqlite", options={"mask_columns": ["phone"]})) await s.commit() ids = {k: r.id for k, r in runs.items()} + # 按 id 交回:同一个测试进程里别的测试留下的应用也可能叫「报销」,按名字找会撞上 + apps = {"报销": app_a.id, "周报": app_b.id} uma = await auth_env.login("uma") bob = await auth_env.login("bob") try: - yield {"admin": admin, "uma": uma, "bob": bob, "runs": ids} + yield {"admin": admin, "uma": uma, "bob": bob, "runs": ids, "apps": apps} finally: async with SessionLocal() as s: for model in (TaskAssignment, Task, AppGrant, AppVersion, App): @@ -191,8 +193,8 @@ async def test_action_receipt_body_is_masked_for_a_user(world): async def test_a_manage_grant_does_not_make_a_user_an_app_builder(world): """§3.12:搭建、发布、绑定岗位、授权、钩子 user 一律不给。授给使用者的 manage 按 observe 算。""" uma = world["uma"] + app_id = world["apps"]["报销"] async with SessionLocal() as s: - app_id = (await s.execute(select(App.id).where(App.name == "报销"))).scalar_one() s.add(AppGrant(app_id=app_id, principal_kind="user", principal="uma", role="manage")) await s.commit() assert (await uma.post(f"/api/apps/{app_id}/publish", json={})).status_code == 403 diff --git a/backend/tests/test_capabilities_registry.py b/backend/tests/test_capabilities_registry.py index ae131b4d..e1ba777b 100644 --- a/backend/tests/test_capabilities_registry.py +++ b/backend/tests/test_capabilities_registry.py @@ -140,6 +140,22 @@ async def test_tool_library_rehearses_anything_governed_as_a_write(world): assert out.json()["rehearsal"] is True and out.json()["result"]["args"] == {"q": "x"} +async def test_tool_list_says_which_tools_only_rehearse(world): + """工具列表上每个工具带 write_rehearsal(和执行接口同一个判断):详情区的提示条照它说「这里只演练,不真的发送」, + 不说「请求会实际发出」。绑定端点的 POST 是写入;GET、Python 工具、没治理成写入的内置工具不是。""" + c = auth_env.client() + tools = {t["id"]: t for t in (await c.get("/api/tools")).json()} + assert tools["erp_pay"]["write_rehearsal"] is True + assert tools["erp_orders"]["write_rehearsal"] is False and tools["calc_py"]["write_rehearsal"] is False + assert tools["file_write"]["write_rehearsal"] is False, "写工作目录不是对外写入,照旧确认后真的执行" + r = await c.put("/api/capabilities/http_request/governance", json={"version": 1, "effect": "write"}) + assert r.status_code == 200, r.text + tools = {t["id"]: t for t in (await c.get("/api/tools")).json()} + assert tools["http_request"]["write_rehearsal"] is True, "治理成写入的内置工具同样只演练" + run = await c.post("/api/tools/http_request/run", json={"args": {"url": "https://example.com"}}) + assert run.status_code == 200 and run.json()["rehearsal"] is True, "列表上的标记和执行接口同一个判断" + + async def test_platform_query_is_by_identity_not_by_shape(world): c = auth_env.client() tabular = {"rows": "/items", "columns": {"单号": "/id"}} diff --git a/frontend/src/apps/Stamp.tsx b/frontend/src/apps/Stamp.tsx index 5a1a2a10..ff3de4e6 100644 --- a/frontend/src/apps/Stamp.tsx +++ b/frontend/src/apps/Stamp.tsx @@ -1,6 +1,9 @@ /** * 印章(应用平台 §2.3):每个视图都带的「截至 / 口径 / 出具档位 / 来源」,服务端算好、这里只照写(P1)。 * + * 截至那一项服务端给的是整句(as_of_text:「截至 2026-10-15 07:29;统计期 …」,没结束的写「尚未结束」),紧凑和完整两种 + * 都照写这一句、不再另加「截至」的标签:标签加上值里的「截至」就成了「截至 截至 …」。 + * * 出具档位按三档配色(完整出具绿、降档琥珀、不予出具红),旁边总有字,不只靠颜色;还没封存的写明「尚未封存」。 * 服务端照实标出的错位(定时任务已改运行新版本、应用还固定在旧版本,引用的版本已退役……)逐条列在下面,琥珀色。 */ @@ -40,7 +43,6 @@ export function Stamp({ stamp, compact = false, className }: { stamp: StampData; ) } const rows: [string, ReactNode, string][] = [ - [STAMP_TEXT.asOf, stamp.as_of_text, 'as_of'], [STAMP_TEXT.caliber, {caliber}, 'caliber'], [STAMP_TEXT.tier, {stamp.tier_text || '—'}, 'tier'], [STAMP_TEXT.from, stamp.from, 'from'], @@ -48,6 +50,8 @@ export function Stamp({ stamp, compact = false, className }: { stamp: StampData; return (
{stamp.as_of_text}