Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/automation-screen-read-trigger-identity-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@objectstack/runtime": patch
---

fix(runtime): the paused-run screen read is gated to the run's trigger identity, or the `sys_automation_run` grant (#7968)

`GET /api/v1/automation/:name/runs/:runId/screen` answered **any authenticated
caller who knew a run id** with the paused run's `ScreenSpec` — and that spec is
not inert with respect to record data. A screen node's `defaults` and per-field
`defaultValue` are interpolated against the live flow variables at suspend time,
so a flow that prefills from its triggering record persists those values into the
spec this route serves.

Measured on a real screen flow over a `crm_lead` record: a caller with valid
auth, no relationship to the run, and explicitly refused the `sys_automation_run`
read grant received `200` with the lead's company in the screen title, its email
address in the description, and its email, phone and salary band as three field
defaults. Reaching it needed only a session plus a leaked or guessed run id.

The route now requires **the identity that triggered the run**
(`ExecutionLogEntry.trigger.userId`) **OR** read access to `sys_automation_run`
as an operator override. A refused caller gets `403 PERMISSION_DENIED`.

**Why not the object grant on its own** — the mechanism the sibling run-state
reads converged on in #7900: it would refuse the screen to the very person the
flow paused for. The pause exists because the flow is asking *that* caller to
fill a form in, so the grant is the override half here, never the whole question.
Operator tooling that already holds the `sys_automation_run` read grant is
unaffected, and so is the end user — including while the permission subsystem is
unreachable, since only the override half fails closed.

**Unchanged**: which runs exist; `resume`'s own per-run `resumeAuthority` checks
(#3801 / #5561); the `404 No pending screen for run` answer, which still comes
back for an unknown or non-paused run id, for every caller, ahead of the gate; the
`501` a deployment without screen lookup returns; and the `401` anonymous floor.

A deployment with no `plugin-security` (no object-permission system at all, so
`/data/sys_automation_run` is itself ungated) keeps answering as before.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,10 @@
* the ruling names and not some second permission invented here.
* 4. **THE AUDIT** — the routes that stay authenticated-only stay
* authenticated-only, and ask the security service nothing. A future change
* to any of those verdicts has to come through this file.
* to any of those verdicts has to come through this file. (#7968 is that
* change, for one row: the paused-run `screen` read left this table when
* the maintainer gated it on the run's trigger identity instead — see the
* note in the table.)
*
* The three non-denials (system context, no security service, partial service)
* are pinned too: each is a decision recorded on `refuseUngrantedRunRead`, and
Expand DownExpand Up@@ -323,7 +326,16 @@ describe('#7900 — /automation run-state reads require the sys_automation_run r
{ path: 'approval_flow', why: 'getFlow — a flow definition, metadata-plane data' },
{ path: 'actions', why: 'getActionDescriptors — the deployment action catalog' },
{ path: '_status', why: 'getFlowRuntimeStates — per-flow enabled/bound state' },
{ path: 'approval_flow/runs/run_7/screen', why: 'the interactive runner\'s re-fetch' },
// [#7968] `approval_flow/runs/run_7/screen` USED to be this table's
// fifth row. The audit's reason for leaving it here was right — the
// grant alone would refuse the end user the flow paused for — but
// "no grant" was not the same as "no gate": the route disclosed
// record-derived screen defaults to any authenticated caller with a
// run id. The 2026-08-12 ruling gates it on the run's own trigger
// identity, with this grant as an operator override, so it is no
// longer authenticated-only and no longer answers `explainCalls ===
// 0` for a caller who is not the trigger identity. Its own file
// owns it now: `automation-screen-read-gate.test.ts`.
];

it.each(AUTHENTICATED_ONLY)('$path stays authenticated-only ($why)', async ({ path }) => {
Expand Down
Loading
Loading