Repository navigation
fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does - #24495
Conversation
…against the request path The flow's data context carries no org_user_ids, so sys_user_org_members drops out and the run sees only the acting user's row. Probe first; the fix follows. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…n setup A runAs:'user' flow's data context carried no org_user_ids, so the shipped sys_user_org_members policy compiled unresolved-variable and dropped out: the run saw only the acting user's own sys_user row while the same user's request saw their organization's members. Run setup now takes the peer list from the grants resolver the plugin bridges to resolveUserAuthzGrants (the request path's resolver), also when the trigger already carried positions/permissions (only the peer list is taken then), and resolveRunDataContext passes it to every data node. A context that already carries one keeps it. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…n-context-org-user-ids
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 74ceec32303cd46660a9db0477731cae51b9ca10 && git checkout 74ceec32303cd46660a9db0477731cae51b9ca10
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45bec43cd981684906a0b4c1a8e15d08ca9ec09f 7e6f99f7a409fdb76cea099a4c232b94737e1095 && git checkout -B drift-repro 45bec43cd981684906a0b4c1a8e15d08ca9ec09f && git merge --no-ff 7e6f99f7a409fdb76cea099a4c232b94737e1095
node scripts/docs-audit/affected-docs.mjs --json 45bec43cd981684906a0b4c1a8e15d08ca9ec09f |
…IdentityContext member The exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, an additive widening of a published package's public surface, so the level is minor and Clause-② is yes (widening). Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The face is the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · #24448 ·
Generated by Claude Code |
Fixes #24448
Clause-②: yes (widening: the exported
RunIdentityContext/RunDataContexttype gains the optionalorg_user_idsmember, the keyExecutionContextalready declares; no accepted input moves)What was wrong
A
runAs: 'user'flow's data context carried noorg_user_ids.sys_user's shipped member scope is two policies inmember_default:sys_user_self(id == current_user.id) andsys_user_org_members(id in current_user.org_user_ids). With the peer list absent, the second compilesunresolved-variableand drops out. So aget_recordonsys_userin such a flow answered the acting user's own row only, while the same user's request answered their organization's members too.Failing probe first
packages/services/service-automation/src/runas-user-org-peers.integration.test.tsboots the real stack: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3:memory:, the identity objects, the real SecurityPlugin with its shipped default permission sets, and the real AutomationEngine with its CRUD nodes. Fixture: organization A (the Default Organization) and organization B.usr_mebelongs to A and B and acts in A;usr_peerbelongs to A;usr_otherbelongs to B only;usr_lonerbelongs to none.Committed alone as
6ca644728and run against the unchanged source:{ userId, tenantId }usr_me, usr_peerexpected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]usr_me, usr_peerexpected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]{ userId }usr_meusr_me(already equal)The control context is
resolveUserAuthzGrants(ql, userId, { tenantId })copied the wayresolveAuthzContextcopies it onto a request.The change
AutomationEngine.resolveRunContext,engine.ts). For arunAs: 'user'run with a user, the engine asks its grants resolver whenever the context carries noorg_user_idsyet, not only when it carries nopermissions. When permissions were already forwarded (the REST trigger, action and endpoint doors forward them), ONLY the peer list is taken from the answer: the forwarded positions and permission sets stay verbatim, so an agent's narrowed ceiling is not re-broadened. A context that already carries a list (asubflow/mapchild, a resumed leg) keeps it and resolves nothing.plugin.ts). The plugin's grants resolver already calledresolveUserAuthzGrants, the resolver the request path seedsorg_user_idsthrough, and dropped the field. It now returns it.FlowUserGrantsgains an optionalorg_user_ids.resolveRunDataContextstays the single mapping (runtime-identity.ts). ItsrunAs: 'user'branch copies the seeded list onto the data context (RunIdentityContext.org_user_ids, optional), so every data node and the lookup expander get it. No list is invented when none was seeded: the policies that read it drop out, which narrows.runAs: 'system'is unchanged.[runAs]warning on a resolver failure now says which half degraded (baseline member permissions, or only the peer list). The existing sentencebaseline member permissions (not elevated)is kept for the full case.Mechanism assumptions, measured
const out: RunIdentityContextblock had noorg_user_ids; the probe above is the failing reading.org_user_ids(buildAutomationContextanddispatchFlowActionin@objectstack/runtimeforward positions/permissions only when non-empty, the record-change trigger forwardsuserId+tenantId). So carrying a trigger's value could fix nothing on its own; the engine seeds at its one run-setup point for every producer. The approvals mirror's hand-built{ isSystem: true, userId }write reaches a flow through the record-change trigger as{ userId }(plustenantIdwhen the hook session has one), which the same seeding covers.resolveRunContext), not per node.sys_memberrows withorganization_id = tenantIdplus the user, from the sameresolveUserAuthzGrantscall the request path makes. The fixture pins it:usr_othershares organization B withusr_meand is still absent when the run acts in A.Ablations (each committed first, mutated through
scripts/ablation-replace.mjs, restored to the HEAD blob withgit diff HEADempty)resolveRunDataContext(if (orgUserIds) out.org_user_ids = orgUserIds;deleted): both organization cases red,expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]; control and no-organization stay green.expected [ 'usr_me', 'usr_other', 'usr_peer' ] to deeply equal [ 'usr_me', 'usr_peer' ](twice) and... to deeply equal [ 'usr_me' ]for the no-organization run.crud-runas.test.tsgo red.Hook path: probed, not in this PR
A temporary probe (not committed) bound a
runAs: 'user'hook that readssys_userthroughctx.apion the same real stack:The second write was the approvals-mirror shape
{ isSystem: true, userId, tenantId }.ScopedContext.withRunAs('user')(packages/objectql/src/engine.ts) pins{ ...executionContext, isSystem: false }, so from a hand-built context the hook carries no positions, no permission sets and noorg_user_ids. Repairing it needs an async resolution through the core resolver inengine.tsorhook-wrappers.ts, or the producers seeding the envelope inplugin-approvals. Neither is inside the rider surface the claim declared (hook-run-as.tsand its test), so the rider is cut and reported fordomain:engine.Tests and gates (head
304dea50d, which mergesorigin/mainatae6bd1a69)pnpm --filter @objectstack/service-automation typecheck: exit 0 (tsc --noEmit, thencheck:test-typecheck: OK).pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2:Test Files 190 passed (190),Tests 2541 passed (2541).node scripts/pm/dispatch-gates.mjs --commands, 68 commands): 67 exit 0;pnpm check:dual-build-cjs-loadsexit 3,PREREQUISITE NOT MET(it reads every package'sdist/), NOT MEASURED here and left toLint & Repo Gates.--ranreconciliation:68 derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED.test:repois not run locally: the diff edits no repo-project test, andTest Coreruns it.origin/mainhas moved 5 commits past the merge base since; none touchesservice-automationor the run identity path, so the branch is not re-merged before CI.Acceptance notes
AutomationContextobject underorg_user_ids, typed by a service-automation-localSeededRunContext, because the claim's file surface excludespackages/spec. The alternative is declaring the key onAutomationContextin@objectstack/spec/contracts, which would let the doors forward the request's list and skip the re-resolution below. Correctness does not need it. Carrier: none.resolveUserAuthzGrants, cache bypassed as the existing bridge already rules). The record-change path already paid it.sys_userrow, until they end.runAs: 'user'run'ssys_automation_run.context_jsonnow carries the peer list beside the positions and permission sets it already carried.tenantIdas handed. Every user-triggered door hands over an already-vetted one. Read-only inference, not probed.accessible_org_ids,systemPermissionsandpostureare not carried into arunAs: 'user'run's data context either; thegroupposture's wall readsaccessible_org_ids. Read-only, not probed. Carrier: none.noas claimed. The only type movement is two optional members:RunIdentityContext.org_user_idsandFlowUserGrants.org_user_ids.Seat's append (patch round 1)
domain:servicesseat 2 re-judgedClause-②from the diff:@objectstack/service-automationisaccess: publicand its index re-exportsRunIdentityContext/RunDataContext, so the new optional member is an additive widening of the published surface. Under the Check Changeset step's WHICH LEVEL rule (ruled 2026-09-04 on #15294) that takesminor. The declaration above and the changeset level are corrected in this round; the last Acceptance-notes line, which says it staysno, is superseded by this append.Generated by Claude Code