Repository navigation
fix(service-automation)!: a runAs:'user' hook fired by a user-triggered runAs:'system' flow's write runs on that user's own grants - #24658
Conversation
…es no positions or permission sets A runAs:'user' hook fired by such a run's write de-elevates to the run's data context and keeps carried arrays verbatim, so the empty arrays ran it on the member baseline. Without them the hook seed resolves the named user's own grants in the run's organisation. A user-less system run keeps today's envelope. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
… on the user's own grants, both directions Real stack: a user granted more than the baseline, a user whose own sets read fewer rows than it, the decider after an approval decision, and the user-less system run as the control. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…stem-run-user-grants
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 3 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aeedf724114461043e103fac43bcb4f24439ea43 && git checkout aeedf724114461043e103fac43bcb4f24439ea43
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 0f452e832bc477f7991713d0d21b4a0b91d4a434 && git checkout -B drift-repro f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 && git merge --no-ff 0f452e832bc477f7991713d0d21b4a0b91d4a434
node scripts/docs-audit/affected-docs.mjs --json f89d29e382e7d50cfe1b2a98742ac1c3d436cd26
|
REWORK · patch round 1 ·
|
…carries no permission sets beside its user The two assertions pinned the system arm's former envelope, which carried empty `permissions` beside the named user; the arm now carries none. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…stem-run-user-grants
Contract reviewServed-tier: Read at 2026-10-19T22:33Z, read-only: the net diff against merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The contract review of record is PASS
Generated by Claude Code |
Fixes #24614
Clause-②: no (narrowing). A
runAs:'user'hook fired by a user-triggeredrunAs:'system'flow's write now runs on that user's own grants instead of the member baseline. For a user whose grants sit below the baseline, as triage's "both directions" names, the hook's reads narrow. #24519's ruling6271152775item 1 declares no arm for its own producer. This seat takes the stricter, narrowing arm until the dev's measurement says whether any principal's grants can sit below the baseline. If none can, the seat re-grades and drops the arm.What changes
resolveRunDataContext'srunAs:'system'arm (packages/services/service-automation/src/runtime-identity.ts) no longer carriespositions: []/permissions: []beside a named user. This applies ruling6271152775(on #24519) to this producer: a context that names a user carries no empty arrays, so the hook seed from PR #24518 resolves that user's own grants in the run's organisation.{ isSystem: true, actor, userId, tenantId?, flowRunId? }. ArunAs:'user'hook one of its writes fires de-elevates to this context, finds nopermissions, and resolves the user's positions, permission sets and peers through the request path's resolver.6267719791Q1 A), so the hooks the post-decision writes fire now run on the decider's grants.isSystem,actor,userIdandtenantIdexactly as before; the run's own authority (the security middleware'sisSystembypass is its first gate, and nothing ahead of it reads the arrays); a user-less system run (a schedule) keepspositions: []andpermissions: [], and arunAs:'user'hook it fires is still refused for want of a user; therunAs:'user'arm.RunIdentityContext/RunDataContextdeclarepositionsandpermissionsoptional, because the named-user system envelope no longer carries them. The changeset names it.The
engine.tsdocblock on the decision segment, which leaned on "a system run's data context carries none", is rewritten to say what the hook now resolves.Measurement (real stack)
runas-system-user-hook-grants.integration.test.ts, besiderunas-user-hook-org-peers.integration.test.ts: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3:memory:, the identity objects, the real SecurityPlugin and the real AutomationEngine with its CRUD nodes. ArunAs:'system'flow triggered by a user writes, and arunAs:'user'hook on the written object reads.PERMISSION_DENIED, 403).runAs:'system'flow pauses at a service-resumable gate and is resumed with a decider. The submit write's hook runs on the submitter's grants and the post-decision write's hook on the decider's.positions: [],permissions: []and nouserId, and the hook is refused withHOOK_UNSCOPED_DATA_ACCESS(403).The existing pins of the old shape moved with it:
crud-runas.test.ts, the system case inrunas-grant-resolution.integration.test.ts(its "resolver not consulted" proof is now no grant-table read and no authz fact filled), the two system-envelope assertions inresume-decider-identity.integration.test.ts, and the tworunAs:'system'expectations inplugin-approvals'restored-approval-continuation.test.ts(theparked()helper's and the post-decision one), which now expect{ userId, isSystem: true }with nopermissionskey.Ablation (one run, on committed
59575b2c4)node scripts/ablation-replace.mjsrestored the empty arrays in the system arm (anchor 1 to 0, blob4817a333tobc398da1) and ran the new file pluscrud-runas.test.ts: 5 failed, 39 passed. The three flow-driven real-stack pins went red: the below-baseline hook read the baseline's rows instead of the user's, the decider test's submit-write hook did the same, and the above-baseline pin failed on its envelope assertion (the write carriedpermissions). The two named-user shape pins incrud-runas.test.tswent red too. GUARD, the user-less CONTROL and the user-less unit pin stayed green, as predicted. Restored: blob back to4817a333(HEAD),git diff HEADempty,git status --porcelainempty.Tests and gates (head
0f452e832, after mergingorigin/mainf89d29e38withos-regen-merge.sh)@objectstack/service-automation:vitest run, 194 files / 2587 tests passed at0f452e832;typecheck(tsc pluscheck:test-typecheck) exit 0.@objectstack/plugin-approvals:vitest runagainst this tree's builtservice-automation, 83 files / 1132 tests passed, 0 failed (every file loads; an earlier count of 1120 left out two mirror files that could not load beforetrigger-record-changewas built);typecheckexit 0, its test-layer debt ledger holding.bf00bb57c:@objectstack/trigger-record-change114/114,@objectstack/trigger-schedule174/174,@objectstack/verify's six system-run files 70/70.dispatch-gates.mjs --commandson the final diff, 70 commands,check:i18nandcheck:i18n-stale-filladded by theplugin-approvalspath): all exit 0, and--rananswered 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN at0f452e832.Round 1 (seat rework record
6277856952): the claim's surface tookplugin-approvals'restored-approval-continuation.test.tsfor its tworunAs:'system'expectations only; both now expect nopermissionsbeside the user, and the package's suite passes in full.Acceptance notes
email, so a row policy oncurrent_user.emailin the user's own sets drops out in such a hook until the email twin lands (code-read, not measured here); objectql: a runAs:'user' hook fired from a hand-built system context gets no email, so every current_user.email row policy hides rows its user can read (the email twin of #24497) #24639 carries it.Generated by Claude Code