Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): converge /automation run-state reads on the sys_automation_run read grant - #7969
Conversation
…ion_run read grant `GET /automation/:name/runs/:runId` answered `deps.success(run)` — the ExecutionLogEntry verbatim, no projection, redaction or masking — behind the #5519 anonymous baseline and nothing else. The only question the surface asked was "are you authenticated?", so any authenticated caller who knew a run id read the triggering record's fields with that record's own FLS never applying. `GET /:name/runs` served the same entries under the same non-gate. The identical snapshot's second door — `sys_automation_run.variables_json` — has always gone through the system object's permissions. Per the maintainer ruling of 2026-08-12, the two doors converge: both run-state reads now consult `ISecurityService.explain({ object: 'sys_automation_run', operation: 'read' })`, which runs the same permission-set resolution, evaluator and RLS compiler the enforcement middleware runs. A caller without the grant gets 403 PERMISSION_DENIED and the automation service is never consulted; a caller with it reads exactly what they read before. Not per-field filtering of `variables` — rejected by the ruling as mechanically undecidable. No new cross-package seam: the `security` slot was already on DomainHandlerDeps. The rest of the domain was audited against the same rule; the routes that stay authenticated-only carry their reason at the route. Fixes#7900 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NKkycFdNjr44tbvpbJa4BA
…omation-read-permission-gate
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…ads now require The "Observing runs" section and the endpoint table documented `GET /:name/runs` and `GET /:name/runs/:runId` with no mention of the permission they require, which after the gate lands understates the contract. Both now name the grant and carry the permission-set snippet, and the callout records why the screen re-fetch is deliberately not gated the same way. Refs #7900 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NKkycFdNjr44tbvpbJa4BA
hotlong
commented
Aug 12, 2026
PM review — Every binding item from the ruling and the dispatch is present: The reverse verification is the strongest part: not "5 tests failed" but the captured body of what the un-gated code actually served — — a 200 carrying the triggering record's fields to a caller the security service had just answered Three judgements beyond the ask, all endorsedOne predicate, not three checks. The gate sits ahead of the service-availability probe. Reasoned one tier up — which permission a route requires must not vary with which automation service a deployment mounts. That is the same ordering lesson as #7653/#7910 and #7911, applied here without being asked. Worth noting that this lane closed the last inverted dispatcher domain this morning and the next PR into the same package arrived already carrying the principle.
The one residual worth the maintainer's attentionThe PR records an asymmetry honestly, and I want it surfaced rather than left in a subsection: the enforcement middleware skips its CRUD gate entirely for an authenticated caller whose permission-set resolution comes back empty ( So on a deployment that configures no baseline permission set at all, this surface refuses where I endorse leaving it: the divergence is in the closed direction on the door being secured, and closing it the other way would mean re-deriving the middleware's own empty-set rule inside the runtime domain, which is precisely the drift Enqueueing when the three remaining checks land. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7900
Implements the maintainer ruling of 2026-08-12 verbatim: the
/automationread surface requires the same permission thesys_automation_runobject read answers with; per-field filtering of the variables map is rejected as the mechanism.The exposure this closes
packages/runtime/src/domains/automation.ts:489-496answered run-detail withdeps.success(run)— theExecutionLogEntryverbatim, no projection, no redaction, no masking. The only gate on the path was the #5519 anonymous baseline, applied to the whole domain rather than per route (:135-147), so the sole question the surface asked was "are you authenticated?". Any authenticated caller who knew a run id read the triggering record's fields with that record's own FLS never applying.GET /:name/runsserved the same entries under the same non-gate.The identical snapshot's second door —
sys_automation_run.variables_json— has always gone through the system object's permissions. One platform, two answers.The mechanism, and why no new seam was built
Both run-state reads now consult
ISecurityService.explain({ object: 'sys_automation_run', operation: 'read' })with the caller's own execution context. That contract exists for exactly this — "the query surface that lets code OUTSIDE the ObjectQL engine middleware ask the same questions the middleware answers when it enforces access", with a standing instruction that a consumer re-deriving these answers locally will drift.explainruns the same permission-set resolution, the samePermissionEvaluatorand the same RLS compiler the enforcement middleware runs (allowed = !capsDeny && crudAllowed && !denyAll && !delegatorMissingover that shared machinery), so this gate cannot answer differently from the/datadoor by drifting.Fork clause: not hit. The
securityslot was already onDomainHandlerDeps(domains/meta.tsresolves it the same way for ADR-0106 masking) andexplainwas already on the contract. No new cross-package seam exists to report, and no second permission system was invented.⛔ Not per-field filtering of
variables— rejected by the ruling, and a granted caller still receives the map untouched (pinned by deep-equal below).Which routes are gated is declared as one predicate (
isRunStateRead), not a check per branch — the point of the ruling is that the domain gets one policy, and a policy spelled at three call sites is three policies that happen to agree today. The gate sits with the #5519 floor and ahead of the service-availability probe, for that gate's own reason read one authorization tier up: which permission a route requires must not vary with which automation service a deployment mounts.Route-by-route audit
Every
/automationroute, with its verdict. The reason for each authenticated-only verdict is in the source at the route, not only here.GET /:name/runs/:runIdsys_automation_runreadExecutionLogEntryverbatim, snapshot andoutputincluded.GET /:name/runssys_automation_runreadGET /:name/runs/:runId/screenscreenthe trigger response already returned inline. Its write siblingresumeanswers on the engine's per-runresumeAuthorityaxis (#3801 / #5561), not on an object grant. Requiring the operator grant would refuse the screen to the person the flow paused for — a breakage, not the narrowing the ruling prices in. Residual is real and not claimed closed (aScreenSpec'sdefaults/defaultValueare interpolated against live flow variables): filed as #7968, which needs a per-run authority read gate, a different mechanism from this ruling.GET /(listFlows)sys_automation_run-class data — no run, no trigger record, no snapshot — so the grant this ruling names says nothing about it.GET /:name(getFlow)/meta, ADR-0106). Narrowing its read posture is a metadata-plane decision and belongs to its own card.GET /actionsGET /connectorsGET /_statusPOST /,PUT /:name,DELETE /:name,POST /:name/trigger,POST /trigger/:name,POST /:name/toggle,POST /:name/runs/:runId/resumeresumein particular already answers on the engine'sresumeAuthorityaxis.The common thread in every authenticated-only verdict except
/screen: requiring thesys_automation_rungrant for flow-definition and registry data would not be convergence — it would be a second policy invented for a different data class, which is what the ruling forbids./screenis the one route where the data class does match and the verdict still differs; that is why it is the one carrying a filed follow-up rather than a closing argument.The three non-denials, each a recorded decision
if (opCtx.context?.isSystem) return next(). A gate that refused what the object read admits is not convergence either.plugin-security⇒ no grant to require. Such a deployment has no object-permission system at all, so/data/sys_automation_runis itself ungated: "authenticated is enough" is what both doors answer, and refusing here would put them in disagreement the other way. The contract mandates this tolerance. Same for a partial service that omitsexplain.explainthrow is a denial. Access-narrowing answers fail closed — the stanceplugin-securityitself takes on an unresolvable object posture (元数据不可解析时的 fail-open 残余风险评估(api-exposure / rest-server) #3545).One stated asymmetry
The middleware skips its CRUD gate entirely for an authenticated caller whose permission-set resolution comes back empty (
if (permissionSets.length > 0)), whileexplainrunscheckObjectPermissionover that empty list and getsfalse. ADR-0090 D5's additive baseline plus the post-resolution fallback make an empty resolution reachable only on a deployment configuring no baseline permission set at all — and there this surface refuses where/datafalls open. Left as-is deliberately: the divergence is in the closed direction on the door this card was filed about, and closing it the other way would mean re-deriving the middleware's own empty-set rule in the runtime domain, which is the driftISecurityServiceexists to prevent. Recorded on the gate.Tests
New:
packages/runtime/src/domains/automation-run-read-permission-gate.test.ts— 18 cases.codeandstatus(ADR-0112) —PERMISSION_DENIED+403— and the automation service is never consulted (getRun/listRunsnot called), so the snapshot is not even loaded for the refused caller. Plus: the refusal body contains neither the caller's positions nor their user id (The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450), and an unresolvable permission answer fails closed.variablesincluded down to the nestedrecordmap, and the runs list is unchanged. Same fixture shapeautomation-run-detail-passthrough.test.tsasserts on, which continues to pass untouched.explainis asked forreadonsys_automation_run, with the caller's own context and nouserId(naming a target user is an administrative act plugin-security gates onmanage_users).401/UNAUTHENTICATEDahead of the new gate.Reverse verification
Fix committed first, then
git checkout origin/main -- packages/runtime/src/domains/automation.ts, suite re-run, then restored from the commit (git statusclean,git diff HEADempty).5 of 18 failed, all as assertion failures — no compile error, no import crash. The observed run-detail response for an explicitly-refused caller on the un-gated code, captured with a temporary probe (since removed):
A 200 carrying the record's fields to a caller the security service had just answered
allowed: falsefor — the finding, reproduced. With the gate restored: 18/18 pass.Docs
content/docs/automation/flows.mdxdocumented both run-state reads — in the "Observing runs" section and in the endpoint table — with no mention of any permission, which after this gate lands understates the contract. Both now name the grant and carry the permission-set snippet, and the callout records why the screen re-fetch is deliberately not gated the same way.content/docs/references/**is generated and untouched; nothing undercontent/docs/releases/is edited.Gates
pnpm -w typecheck— clean (126/126 tasks).pnpm check:type-check-debt—OK … none above its recorded number.@objectstack/runtimestays at its recorded 227; the new test file adds 0 errors, and no ledger number was raised.pnpm --filter @objectstack/runtime test— 140 files / 2146 tests pass (re-run after mergingorigin/main).pnpm --filter @objectstack/http-conformance test— 72 pass.pnpm check:doc-anchors(210 fragment links) andpnpm check:doc-authoring(375 files) — clean.Migration
Carried in the changeset (
.changeset/automation-run-read-permission-gate.md), because this is a behaviour narrowing and deployments need to pre-grant before upgrading:Out-of-scope finding filed
GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 —GET /:name/runs/:runId/screenstill discloses record-derived values (interpolated screen defaults) to any authenticated caller who knows a run id. Filed unassigned, with the reason it cannot be closed by this ruling's mechanism.🤖 Generated with Claude Code
https://claude.ai/code/session_01NKkycFdNjr44tbvpbJa4BA