Repository navigation
fix(service-automation): a runAs:'user' flow carries its user's email, so an email-keyed row policy admits the run as it admits the user's own request - #24636
Conversation
…' run identity Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…ack, and the decider's Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…n-identity-email Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 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 ca573188e0ea75500eaf05a84dd5dea92cecaf24 && git checkout ca573188e0ea75500eaf05a84dd5dea92cecaf24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 973917c5795f7a440d46a15bc5cf8961af9d8703 5318e581015920941957e2650d6115310041011d && git checkout -B drift-repro 973917c5795f7a440d46a15bc5cf8961af9d8703 && git merge --no-ff 5318e581015920941957e2650d6115310041011d
node scripts/docs-audit/affected-docs.mjs --json 973917c5795f7a440d46a15bc5cf8961af9d8703
|
Contract reviewServed-tier: Read at 2026-10-19T19:29Z on PR #24636's head: card #24613 (its body, triage 6272563947, claim 6272823429 and the os-dev-report 6274876489), the PR body and its one comment (the advisory docs-drift check), the net diff against merge base ① Derived judgmentsD1 — a D2 — the decider's segment (PR #24527's path): RIGHT. D3 — the D4 — the flow CEL D5 — the published surface: RIGHT, with one wording nit. D6 — the seeding rule and its fail-safe: RIGHT. Only a missing fact is resolved; carried positions and permission sets are never re-broadened (the ADR-0090 agent-ceiling case keeps its narrowing). A resolver that answers no email seeds none ( D7 — tests, pins and the head's gates: RIGHT. The real-stack pin sits beside ② Semver levelThe changeset ③ Boundary flagsDev deviations, each read against the diff:
Open question (the flow CEL Out-of-scope findings:
Claim exclusions held: the net diff touches nothing under Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The contract review of record is PASS
Generated by Claude Code |
Fixes #24613
Clause-②: yes (widening).
AutomationContext.emailand the exportedFlowUserGrants/RunIdentityContexttypes gain one optional member each. A run reaches rows its user's own request reaches, and nothing beyond.What was wrong
A
runAs: 'user'flow's data context carried noemail. The RLS compiler bindscurrent_user.emailfrom the execution context'semailand nothing else, so every row policy keyed on it (the stock showcase'stask_own_rows,assignee == current_user.email) dropped out inside the run. A record-change flow triggered by a member whose only access to a row is such a policy was refused on that row, while the member's own request reads and updates it. The request path gets the email from the core resolver (resolveUserAuthzGrants, throughresolveAuthzContext); the run called the same resolver and dropped the field at every step.The change
plugin.ts). The grants resolver the plugin wires already calledresolveUserAuthzGrants; it now returnsgrants.emailbeside the positions, permission sets and peer list.FlowUserGrantsgains an optionalemail.seedRunGrants,engine.ts), by the rule the peer list uses: only what the context lacks. ArunAs: 'user'context with a user and no email is resolved and takes the email; a context that already carries one keeps it verbatim (the request path keeps a session's email the same way). Forwarded positions and permission sets stay untouched.resolveDecisionSegmentContext). It now removes the submitter'semailwith the submitter's other facts, on both postures, andseedRunGrantsresolves the decider's. Without the removal the decider would run with the submitter's email under the decider's id.resolveRunDataContextstays the single mapping (runtime-identity.ts). ItsrunAs: 'user'arm copies the email onto the data context (RunIdentityContext.email, optional). No email is invented when none was seeded. TherunAs: 'system'arm is untouched.packages/spec/src/contracts/automation-service.ts, a spec-lane rider):AutomationContext.email?: stringwith TSDoc naming who sets it, who reads it, and what its absence means. It regenerates no artefact:pnpm --filter @objectstack/spec check:generatedreports all 14 generated artefacts up to date.[runAs]warning on a resolver failure names the email beside the peer list.runUserOf, the CEL scope note).Measurement: the card's reproduction on the real stack
packages/services/service-automation/src/runas-user-email-owner.integration.test.ts, besiderunas-user-hook-org-peers.integration.test.ts. It boots ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3:memory:, the identity objects, the real SecurityPlugin (shipped sets plus one fixture set:selectandupdateonassignee == current_user.email) and the real AutomationEngine with its CRUD nodes. Runs start with the record-change trigger's own envelope (record,object,event,userId,tenantId,params).@objectstack/trigger-record-changedepends on this package, so it is not composed here.Before: the three producer files at
c3954057f(git restore --source, each file's blob hash checked against the base blob), the new pin at head. After: head.update_record(qa_owner_task) failed: Record ID not found in qa_owner_tasknotesequals the member's idrunAs: 'system'runBefore-leg totals:
Tests 3 failed | 5 passed (8). After:Tests 8 passed (8). The card's showcase run reported the record-level 403 sentence; this stack answers the not-found that by-id writes of an unreadable row answer. Both are the refusal of a row the member can read.Ablations (committed first, mutated through
scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty)The subjects resolve through relative imports to
src/, so no build sits between the mutation and the run.if (needsEmail && grants.email) runContext.email = grants.email;deleted): 7 red across three files. The card's reproduction, the doors' shape and the decider's row on the real stack (Record ID not found), the decider envelope (after.email), and three unit pins incrud-runas.test.ts.Tests 7 failed | 59 passed (66).delete switched.email;deleted): 6 red. On the real stack the submitter's row is written by the decider (expected true to be false) and the decider's own row is refused. In the envelope layer:expected 'rep@example.test' to be 'mgr@example.test', the failed-resolution case (expected 'rep@example.test' to be undefined), and bothrunAs: 'system'decision cases.Tests 6 failed | 23 passed (29).Reverse type check: an
AutomationContextliteral withemail: 42planted inruntime-identity.tsgivesTS2322: Type 'number' is not assignable to type 'string'undertsc --noEmit. So the type check reads the rebuilt.d.tsthat declares the member. A stale one would report an unknown property. Restored to the HEAD blob.Tests and gates (head
5318e5810, which mergesorigin/mainat973917c57)pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2:Test Files 193 passed (193),Tests 2580 passed (2580).pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit, thencheck:test-typecheck: OK).pnpm --filter @objectstack/spec typecheck: exit 0.vitest run --project local --maxWorkers=2(thetestscript's runner):Test Files 701 passed (701),Tests 21312 passed | 1 todo. Spec run at0663fe26c; the merge moved no file underpackages/spec.pnpm --filter @objectstack/spec check:generated: all 14 generated artefacts up to date.node scripts/pm/dispatch-gates.mjs --commands, 90 commands) on5318e5810: all 90 exit 0, each recorded with its exit code.--ran:90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED(0 UNRUN). An earlier run at0663fe26chad one exit 3,check:dual-build-cjs-loads(PREREQUISITE NOT MET, it reads every package'sdist/). It exited 0 once the dists were built, and on the final head it exits 0 on the first run.check:dev-prereqs(local-only family; this diff touchespackages/spec): exit 0.test:repois not run locally: the diff edits no repo-project test, andTest Coreruns it.Acceptance notes
ScopedContext[SEED_USER_GRANTS](packages/objectql) seeds arunAs: 'user'hook's positions, permission sets and peer list, but not the email. It is outside this card's surface and is reported to the seat for thedomain:enginelane. A hook fired by arunAs: 'user'flow's write now inherits the run's email through the write's context.current_userstill binds no email. The run context now holds one for arunAs: 'user'run. The binding follows the ruled list (id, positions, organization, the platform-admin flag), and the email serves the data operations' row policies. A pin inflow-cel-current-user.test.tsholds that. Whether the binding should widen is a question for the seat, not a change made here.runAs: 'user'run'ssys_automation_run.context_jsonnow carries the user's email beside the positions and permission sets it already carried. That row's read is governed bysys_automation_run's own permissions.subflow/mapchild of an email-less user, or an in-process caller that resolved the rest itself. Every door-started run already paid this resolution for the peer list.packages/spec/src/contracts/: the claim's surface names onlyautomation-service.tsthere. The member's type is exercised by the service-automation test layer (check:test-typecheck) and by the reverse type check above.Generated by Claude Code