Skip to content

fix(runtime): converge /automation run-state reads on the sys_automation_run read grant - #7969

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-7900-automation-read-permission-gate
Aug 12, 2026
Merged

fix(runtime): converge /automation run-state reads on the sys_automation_run read grant#7969
hotlong merged 3 commits into
mainfrom
claude/issue-7900-automation-read-permission-gate

Conversation

@hotlong

@hotlonghotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes#7900

Implements the maintainer ruling of 2026-08-12 verbatim: the /automation read surface requires the same permission the sys_automation_run object 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-496 answered run-detail with deps.success(run) — the ExecutionLogEntry verbatim, 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/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. 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. explain runs the same permission-set resolution, the same PermissionEvaluator and the same RLS compiler the enforcement middleware runs (allowed = !capsDeny && crudAllowed && !denyAll && !delegatorMissing over that shared machinery), so this gate cannot answer differently from the /data door by drifting.

Fork clause: not hit. The security slot was already on DomainHandlerDeps (domains/meta.ts resolves it the same way for ADR-0106 masking) and explain was 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 /automation route, with its verdict. The reason for each authenticated-only verdict is in the source at the route, not only here.

RouteVerdictReason
GET /:name/runs/:runId🔒 sys_automation_run readThe measured exposure. Serves the ExecutionLogEntry verbatim, snapshot and output included.
GET /:name/runs🔒 sys_automation_run readServes the same entries a page at a time — same data class, same door.
GET /:name/runs/:runId/screenauthenticated-onlyThe end user's surface, not the operator's: the pause exists because the flow is asking this caller to fill a form in, and the route is the refresh-safe re-fetch of a screen the trigger response already returned inline. Its write sibling resume answers on the engine's per-run resumeAuthority axis (#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 (a ScreenSpec's defaults/defaultValue are interpolated against live flow variables): filed as #7968, which needs a per-run authority read gate, a different mechanism from this ruling.
GET / (listFlows)authenticated-onlyFlow names. Not 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)authenticated-onlyA flow definition: metadata-plane data, governed on the metadata plane (/meta, ADR-0106). Narrowing its read posture is a metadata-plane decision and belongs to its own card.
GET /actionsauthenticated-onlyThe deployment's action catalog (ADR-0018) — a static registry, no run data.
GET /connectorsauthenticated-onlyThe deployment's connector catalog (ADR-0022) — same.
GET /_statusauthenticated-onlyPer-flow enabled/bound runtime state. Flow state, not run state.
POST /, PUT /:name, DELETE /:name, POST /:name/trigger, POST /trigger/:name, POST /:name/toggle, POST /:name/runs/:runId/resumeout of scopeWrites. The ruling is about the read surface; resume in particular already answers on the engine's resumeAuthority axis.

The common thread in every authenticated-only verdict except /screen: requiring the sys_automation_run grant 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. /screen is 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

  1. System context passes. The middleware's very first act is if (opCtx.context?.isSystem) return next(). A gate that refused what the object read admits is not convergence either.
  2. No plugin-security ⇒ no grant to require. Such a deployment has no object-permission system at all, so /data/sys_automation_run is 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 omits explain.
  3. An explain throw is a denial. Access-narrowing answers fail closed — the stance plugin-security itself 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)), while explain runs checkObjectPermission over that empty list and gets false. 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 /data falls 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 drift ISecurityService exists to prevent. Recorded on the gate.

Tests

New: packages/runtime/src/domains/automation-run-read-permission-gate.test.ts — 18 cases.

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 status clean, git diff HEAD empty).

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):

{"status":200,"body":{"success":true,"data":{"id":"run_7","status":"paused",
"variables":{"record":{"id":"ord_1","amount":90000,"margin_pct":4.5}}}}} | getRun called: 1

A 200 carrying the record's fields to a caller the security service had just answered allowed: false for — the finding, reproduced. With the gate restored: 18/18 pass.

Docs

content/docs/automation/flows.mdx documented 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 under content/docs/releases/ is edited.

Gates

  • pnpm -w typecheck — clean (126/126 tasks).
  • pnpm check:type-check-debtOK … none above its recorded number. @objectstack/runtime stays 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 merging origin/main).
  • pnpm --filter @objectstack/http-conformance test — 72 pass.
  • pnpm check:doc-anchors (210 fragment links) and pnpm check:doc-authoring (375 files) — clean.
  • ESLint clean on the changed source files.

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:

Any identity that reads automation run history or run detail over HTTP — operator dashboards, monitoring pollers of GET /automation/:name/runs?status=failed, support tooling that opens a run by id, scripted health checks — must now hold read on sys_automation_run:

permissions: [{name: 'automation_operator',objects: {sys_automation_run: {allowRead: true}}}]

Nothing else changes for a caller that already holds it — the response body is unchanged, variables map included. System-context callers and the engine's internal paths never go through this seam. Screen-flow end users are unaffected.

Out-of-scope finding filed

🤖 Generated with Claude Code

https://claude.ai/code/session_01NKkycFdNjr44tbvpbJa4BA

…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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 9:11am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/runtime)
  • content/docs/plugins/packages.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/runtime)
  • content/docs/releases/v17.mdx(via @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:cli seat (#6024). Verdict: accept, pending the three checks still running (Test Core 1/3 and 3/3, TypeScript Type Check). No change requests. One residual for the maintainer's eyes, below — not a blocker.

Every binding item from the ruling and the dispatch is present: explain({ object: 'sys_automation_run', operation: 'read' }) as the mechanism; per-field filtering of variables refused and pinned refused (a granted caller deep-equals the fixture down to the nested record map); refusal asserting codeandstatus; the route-by-route audit with a stated reason for every authenticated-only verdict; the migration note in the changeset with the actual permission snippet. The fork clause is answered rather than skipped — security was already on DomainHandlerDeps (domains/meta.ts resolves it the same way for ADR-0106) and explain already on the contract, so there was no new seam to report.

The reverse verification is the strongest part: not "5 tests failed" but the captured body of what the un-gated code actually served —

{"status":200,"body":{"success":true,"data":{"id":"run_7","status":"paused",
"variables":{"record":{"id":"ord_1","amount":90000,"margin_pct":4.5}}}}} | getRun called: 1

— a 200 carrying the triggering record's fields to a caller the security service had just answered allowed: false for. That is #7900's finding reproduced, not merely re-argued.

Three judgements beyond the ask, all endorsed

One predicate, not three checks.isRunStateRead in one place, with the reason stated: "a policy spelled at three call sites is three policies that happen to agree today." The ruling's whole point was that the domain gets one policy; implementing it as three agreeing conditionals would have satisfied the letter and lost the substance.

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.

/screen is the one route where the data class matches and the verdict still differs — and it is filed, not argued away. The reasoning holds: the pause exists because the flow is asking that caller to fill in a form, its write sibling resume answers on the engine's per-run resumeAuthority axis rather than an object grant, and requiring the operator grant would refuse the screen to the person the flow paused for. The residual is real (interpolated ScreenSpec defaults are record-derived) and #7968 says so plainly instead of claiming closure. That is the correct shape for a route the ruling's mechanism cannot reach.

The one residual worth the maintainer's attention

The 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 (if (permissionSets.length > 0)), while explain runs checkObjectPermission over that empty list and returns false.

So on a deployment that configures no baseline permission set at all, this surface refuses where /data/sys_automation_run falls open — the two doors disagree, in the opposite direction from the one this card was filed about.

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 ISecurityService exists to prevent. But the operational consequence should be said out loud rather than inferred from the changeset: on such a deployment, operator dashboards and monitoring pollers go dark on upgrade. The remedy is the same one the migration note already gives — configure a permission set granting sys_automation_run read — so it is recoverable, not a trap. ADR-0090 D5's additive baseline plus the post-resolution fallback make the precondition rare, and it is stated on the gate in source.

Enqueueing when the three remaining checks land.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 12, 2026 09:34
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 627e65aAug 12, 2026
28 checks passed
@hotlong
hotlong deleted the claude/issue-7900-automation-read-permission-gate branch August 12, 2026 10:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude