Skip to content

Commit 627e65a

Browse files
hotlongclaude
andauthored
fix(runtime): converge /automation run-state reads on the sys_automation_run read grant (#7969)
* fix(runtime): converge /automation run-state reads on the sys_automation_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 * docs(automation): state the sys_automation_run grant the run-state reads 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86d2e5e commit 627e65a

4 files changed

Lines changed: 664 additions & 6 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(runtime): `/automation` run-state reads require the `sys_automation_run` read grant (#7900)
6+
7+
**⚠️ BEHAVIOUR NARROWING — pre-grant before you upgrade.** Operator tooling that
8+
read automation run detail with bare authentication now needs read access to the
9+
`sys_automation_run` object. See the migration note at the bottom.
10+
11+
**What was open.** `GET /automation/:name/runs/:runId` answered with
12+
`deps.success(run)` — the `ExecutionLogEntry` verbatim, no projection, no
13+
redaction, no masking, on any field. The only gate on that path was the #5519
14+
anonymous baseline, applied to the whole `/automation` domain rather than per
15+
route, so the sole question the surface asked was *"are you authenticated?"*. Any
16+
authenticated caller who knew a run id read whatever that run's log entry held —
17+
including, through the variables snapshot and through `output`, the triggering
18+
record's fields, **with that record's own field-level security never applying**.
19+
`GET /:name/runs` served the same entries a page at a time, gated the same way.
20+
21+
The identical snapshot has always had a second, differently-gated door:
22+
`sys_automation_run.variables_json` persists it for every paused run, and that
23+
read goes through the system object's permissions. One platform, two answers,
24+
depending on which door you knocked on.
25+
26+
**What this does — converge the two doors** (maintainer ruling, 2026-08-12). Both
27+
run-state reads now consult the same permission the `sys_automation_run` object
28+
read answers with: `ISecurityService.explain({ object: 'sys_automation_run',
29+
operation: 'read' })`, which runs the same permission-set resolution, the same
30+
`PermissionEvaluator` and the same RLS compiler the enforcement middleware runs.
31+
No new permission system, no new cross-package seam — the `security` slot was
32+
already on `DomainHandlerDeps`. A caller without the grant gets **403
33+
`PERMISSION_DENIED`**, and the automation service is never consulted, so the
34+
snapshot is not even loaded. A caller **with** the grant reads exactly what they
35+
read before, byte for byte.
36+
37+
**Not** per-field filtering of `variables` — explicitly rejected by the ruling, on
38+
the measurement that the map's keys (`.`, `record`, `previous`, `$runId`, seeded
39+
inputs) are not decidably record fields, so a per-field rule is one an
40+
implementation can get quietly wrong.
41+
42+
**The rest of the domain was audited against the same rule**, and the routes that
43+
stay authenticated-only carry their reason in the source rather than in silence:
44+
`GET /`, `GET /:name`, `GET /actions`, `GET /connectors` and `GET /_status` serve
45+
flow-definition and registry data, not `sys_automation_run`-class data, so the
46+
grant this ruling names says nothing about them and requiring it would invent a
47+
second policy rather than converge one. `GET /:name/runs/:runId/screen` is the
48+
interactive runner's refresh-safe re-fetch for the caller the flow paused *for*,
49+
and its write sibling `resume` already answers on the engine's per-run
50+
`resumeAuthority` axis; its residual disclosure (a screen's defaults are
51+
interpolated against live flow variables) is filed separately rather than closed
52+
by an operator grant that would refuse the end user.
53+
54+
Three non-denials, each deliberate: a **system** context passes (the middleware's
55+
own first bypass); a deployment with **no `plugin-security`** passes, because
56+
there is no object-permission system for either door to consult and refusing
57+
would put them in disagreement the other way; a **partial** security service that
58+
omits `explain` degrades rather than throwing. An `explain` that throws is a
59+
denial — an access-narrowing answer fails closed.
60+
61+
---
62+
63+
### Migration
64+
65+
Deployments upgrading to this release should **pre-grant before upgrading**.
66+
67+
Any identity that reads automation run history or run detail over HTTP —
68+
operator dashboards, monitoring pollers of `GET /automation/:name/runs?status=failed`,
69+
support tooling that opens a run by id, scripted health checks — must now hold
70+
**read on `sys_automation_run`** in one of its permission sets:
71+
72+
```ts
73+
permissions: [{
74+
name: 'automation_operator',
75+
objects: { sys_automation_run: { allowRead: true } },
76+
}]
77+
```
78+
79+
Nothing else changes for a caller that already holds it: the response body is
80+
unchanged, including the full `variables` map. Service/system-context callers and
81+
the engine's own internal paths are unaffected — neither goes through this seam.
82+
Screen-flow end users are unaffected: the screen re-fetch is not gated.

content/docs/automation/flows.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,25 @@ GET /api/v1/automation/{flow}/runs/{runId} # one run
684684
GET /api/v1/automation/{flow}/runs/{runId}/screen # re-fetch a paused screen
685685
```
686686

687+
<Callout type="warn" title="The two run-state reads require read access to sys_automation_run">
688+
A run carries the flow's variable snapshot and its `output` — which is to say
689+
the triggering record's fields. So `GET /runs` and `GET /runs/{runId}` are gated
690+
on the same grant the `sys_automation_run` object read answers with, not on
691+
authentication alone; a caller without it gets `403 PERMISSION_DENIED`. Grant it
692+
to the identities your operator tooling and monitoring run as:
693+
694+
```ts
695+
permissions: [{
696+
name: 'automation_operator',
697+
objects: { sys_automation_run: { allowRead: true } },
698+
}]
699+
```
700+
701+
The screen re-fetch is deliberately **not** gated this way — it serves the end
702+
user the flow paused for, and its `resume` counterpart answers on the pause's
703+
own `resumeAuthority` instead.
704+
</Callout>
705+
687706
Each run's `steps[]` records every executed node — including loop iterations,
688707
parallel branch bodies, and try/catch region steps — which the Studio flow
689708
designer surfaces, nested by iteration / branch / handler, in its **Runs** side
@@ -1238,8 +1257,8 @@ curl -b cookies.txt -X POST \
12381257
| Endpoint | Purpose |
12391258
|:---|:---|
12401259
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
1241-
| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`, `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`) |
1242-
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`) |
1260+
| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`, `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
1261+
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
12431262
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
12441263
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |
12451264

0 commit comments

Comments
 (0)