Repository navigation
fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does - #24518
Conversation
…s_user read from a hand-built context Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
…d peer list, seeded through the request path's resolver Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
…seed Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
…-context census) Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 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 6c207282225fa8ab2fe6c6c95b925b8580dd6120 && git checkout 6c207282225fa8ab2fe6c6c95b925b8580dd6120
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f4755a68a797d5a000cb8bd1c942cb2a9862108e f094a9d49cf8d19e2847731684c243854db03621 && git checkout -B drift-repro f4755a68a797d5a000cb8bd1c942cb2a9862108e && git merge --no-ff f094a9d49cf8d19e2847731684c243854db03621
node scripts/docs-audit/affected-docs.mjs --json f4755a68a797d5a000cb8bd1c942cb2a9862108e
|
Contract reviewServed-tier: ① Derived judgmentsWhat a
Open question 1, the widening from the baseline to the user's real grants. It is exactly what the same user's request holds, and never more. The tenant the seed resolves under is the triggering context's Failure mode. The rejection arm of
The grants cache, open question 3. The census. The first version read #24341's precondition. On a One derivation outside the seam's walk. ② Semver level
The method can be kept off the public face, and that is the reviewer's call, because it also closes the seam-walk gap in ①: key the member on a symbol declared in If the seat instead keeps the public method, the PR body's second line and the changeset's last line must both read Either way the head as committed does not hold on ②. The access face is accepted as it stands. ③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…t's nameable public face Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsWhat moved, and what did not. The fix commit touches four files, +34/-9:
Under WHICH LEVEL this is no nameable widening. The rule's act is "a purely additive widening of a published package's public surface (a new exported symbol on an The seam-walk gap closes. The new face pin is load-bearing for the regression it names. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24497. 6 files, +467/−11. Not governed. |
| Scope | A runAs: 'user' hook whose triggering context carries no permissions or no org_user_ids is seeded where its runAs is resolved. The seed is ScopedContext[SEED_USER_GRANTS](), awaited by installRunAsApi through seedHookUserGrants. It makes one resolveUserAuthzGrants call under the trigger's tenantId, by PR #24495's rule: carried arrays stay verbatim, and a context that carries both resolves nothing. 'inherit', 'system' and the UnscopedHookApi refusal are unchanged, and withRunAs stays synchronous. |
| Seat's own read | The fix round's diff is the rename to the symbol key, the symbol's declaration and one pin. The changeset and the probe are the same blobs. index.ts at the head names SEED_USER_GRANTS 0 times. |
| Review | CONTRACT_REVIEW_TIER record 6265937412, FAIL on e82725a18 on ② alone (a public method on the exported ScopedContext). The seat ruled route A (off the public face). Delta record 6267161870 is PASS on this head; every access judgment of 6265937412 carries. |
| Declaration | Clause-②: no on the claim, the PR body and the changeset (@objectstack/objectql patch). It holds now: no nameable member or exported symbol is added. |
| Cross-lane | The real-stack probe in service-automation is declared to domain:services: 6265672952 on #6021 and 6265682549 on #21118. No objection. The PR body was patched to the symbol form by the seat. |
| Gates | Dev, on f094a9d49c: 72 derived, 72 run, 72 exit 0. Core test:repo 82/82. |
| CI | At this read on f094a9d49c: every check completed, none failed. |
Disposition:
open_questions:- 1 (the seed also takes the user's real grants when the trigger carries none) is accepted as A. Record 6265937412 ①: it is exactly the request's envelope, as a subset.
- 2 is answered by the fix round (route A).
- 3 (no grants-cache bypass) is accepted as A.
deviations: all accepted (record 6265937412 ③).out_of_scope_findings: the twoplugin-approvalsproducer gaps are filed as plugin-approvals: the hand-built contexts its writes run under don't fully carry the deciding user's context, so a runAs:'user' hook they fire gets neither the user's organisation peers nor the user's own grants (the producer half of #24497) #24519 (finding, for triage, routed todomain:services), with finding 1 corrected to itsmirrorStatusFieldhalf. The third (systemPermissions,accessible_org_ids, posture) is noted, not filed, as on the flow side.- For platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an
extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341: its precondition is met for approvals' envelopes. A hook's pin now always carriesorg_user_ids(at least the user), except after a resolver failure.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24497 then closes the card.
Generated by Claude Code
Fixes #24497
Clause-②: no (a runtime producer fix: a
runAs: 'user'hook reads what the same user's own request reads. Nothing@objectstack/specdeclares moves; owes the seat's review of record before the queue)What was wrong
ScopedContext.withRunAs('user')pins a hook'sctx.apito{ ...triggering context, isSystem: false }and seeds nothing. A hook fired from a hand-built{ isSystem: true, userId, tenantId }context therefore carried noorg_user_ids. Thesys_user_org_memberspolicy (id in current_user.org_user_ids) compiledunresolved-variableand dropped out, so the hook saw the acting user's ownsys_userrow only. The same user's request saw their organisation's members too. This is the hook-side twin of #24448 (PR #24495), and a precondition of #24341: under its one-policysys_userdefault, such a hook would lose its own row as well.Measurement (real stack, before and after)
The probe is
packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts. It boots ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3:memory:, the identity objects (createIdentityObjectsPlugin) and the real SecurityPlugin with its shipped default permission sets. ArunAs: 'user'hook onhook_probe'safterInsertreadssys_userthroughctx.api, and a middleware records the context that read carries.Fixture: organisation A (the Default Organization) and organisation B.
usr_mebelongs to A and B and acts in A.usr_peerbelongs to A.usr_otherbelongs to B only.usr_lonerbelongs to none.The probe was committed alone first (
1f1afac08) and run against unchangedorigin/main666283aea. The after column is heade82725a18.{ isSystem: true, userId: usr_me, tenantId: A }(the card's shape)usr_me/ no positions, no permissions, noorg_user_idsusr_me, usr_peer/ positionsorg_member, everyone, permissionsmember_default,org_user_idsusr_me, usr_peerusr_me, usr_peer/ envelope verbatimusr_me, usr_peer/ envelope verbatim, nothing resolved{ isSystem: true, positions: [], permissions: [], userId: usr_me }usr_me/org_user_idsabsent (read from the ablation leg)usr_me/org_user_idsusr_me, positions and permissions[]verbatim{ isSystem: true, userId: usr_me }usr_meusr_me/org_user_idsusr_meusr_otherandusr_lonerare never in any answer. The CONTROL case, the user's own request acting in A, answersusr_me, usr_peeron both trees.The probe does not fire through
plugin-approvals' real write path. No package composes approvals, the SecurityPlugin and the identity objects in its dev dependencies.plugin-approvalslacksplugin-securityandplugin-auth;service-automationandruntimelackplugin-approvals;qa/dogfoodandclihave all three but boot the full app. Adding a dev dependency would move the lockfile outside this card's fence. So the probe fires from the approvals' exact envelope, copied fromSYSTEM_CTXinapproval-service.tsandapproval-node.ts.The change
ScopedContext[SEED_USER_GRANTS]()(engine.ts, theScopedContextclass), keyed on a symbol declared inhook-run-as.tsand not exported from the package entry, is an async method, the second half of the'user'derivation. It applies fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does #24495's run-setup rule to the hook's pinned context:resolveUserAuthzGrants(@objectstack/core), the resolver the request path seeds through. There is no second resolver and no second definition of "peer".permissionstakes the resolved positions and permission sets.org_user_idsresolves nothing.withRunAs('user')produced.seedHookUserGrants(hook-run-as.ts) is the step the wrapper awaits afterderiveHookApi. An api that cannot seed, such as the refusingUnscopedHookApi, is returned as is.installRunAsApi(hook-wrappers.ts) is now async for'user'only.'inherit'takes no extraawaitand keeps the engine-built api by reference.'system'installssudo()as before.UnscopedHookApirefusal (nouserId) does not move.warnnames the consequence. This is narrower, never elevated, and the same posture as the flow run setup.Why
withRunAsstays synchronous.runtime's stored-metadata seams (deriveThroughSeam) wrapwithRunAs's return value synchronously. A Promise there would hand back an unwrapped api, which is a route around the seam. So the async half is a separate method, reached by the wrapper whilectx.apiis still the engine-builtScopedContext. The sandbox body layers wrap it later, at handler time.Cost. A
runAs: 'user'hook whose triggering context lackspermissionsororg_user_idspays oneresolveUserAuthzGrantsper dispatch: one wave of five concurrent reads, plus the permission-set reads the resolver makes. A request-resolved context carries both and pays nothing. The request-grants memo does not apply here, because it is open only insideresolveAuthzContext. The #11971 grants cache answers it whenOS_AUTHZ_GRANTS_CACHE_TTL_MSis enabled; it is off by default. A hook is request-shaped, so it is not added to the cache's ruled bypass list.Where the files landed vs the claim's fence. The three source files are in the fence. Outside it:
engine.tsgains one import line forresolveUserAuthzGrantsbeside its existing@objectstack/coreimports. That is not PR fix(lint,objectql)!: a validation rule reading a field its lookup target does not declare is refused at save, and no longer leaves its sibling rules without a verdict #24508's predicate region.service-automation, beside its flow twin. That package is the one whose dev dependencies already compose the whole stack;objectqlcannot depend on driver-sql, plugin-auth or plugin-security.Pins
hook-run-as.test.ts, new[#24497]block (layer 1, a realScopedContextover a recording engine that answers the resolver's reads). It covers:'system'and'inherit'resolve nothing, and the user-less refusal holds;'user' DE-ELEVATESreadengine.seen[0], which is now the resolver's first read. It now reads the hook's ownupdateand still assertsisSystem: falseanduserId. Swept for kin:runtime/src/sandbox/hook-run-as.integration.test.tspins only the user-less refusal for'user', which does not move. Every otherrunAstest outside these files pins flows or'system'.Ablation (committed first, mutated through
scripts/ablation-replace.mjs)withUserGrantsSeededate82725a18;[SEED_USER_GRANTS]fromf094a9d49, re-ablated in fix round 1) returnsthisunconditionally. The marker was proved indist/byablation-dist-preflight.mjs, in 4 built files.expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ](the card's shape);expected undefined to deeply equal [ 'usr_me' ](approvals' envelope).git diff HEADempty, rebuilt, marker--absentfrom all 14 built files. 5/5 and 20/20 green again.Tests and gates (head
e82725a18, which mergesorigin/mainat3ae0b777d)pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck: OK, debt held).pnpm --filter @objectstack/service-automation typecheck: exit 0.ScopedContext, the hook wrappers,runAsorctx.api: 49 passed, 860 tests.Test Coreruns the full suite.runas-*andcrud-runas: 6 files, 55 tests passed.pnpm --filter @objectstack/core test:repo: 6 files, 82 tests passed.pnpm --filter @objectstack/objectql test:repo: 1 file, 5 tests passed.[#22661]census keys onreferenceTargetOfandfederated-injected-column-readerskeys on injected-column seams; this diff calls neither. Both pass.node scripts/pm/dispatch-gates.mjs --commands, no paths, 72 commands): all 72 exit 0.check:system-context-censusfirst went red on a new elevation read in the seed method. The read was removed (e82725a18), not added as a census row.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET). After a fullturbo run build, it exited 0.--ran:72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED.Acceptance notes
mirrorStatusField) deliberately carries notenantId, andapproval-node.tscarriesorganizationId, which is not an envelope field, rather thantenantId. Either way the seeded list is the organisation-less one, the user alone, as the table shows.extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341's one-policy default (id == current_user.id || id in current_user.org_user_ids), such a hook keeps its own row instead of losing it.plugin-approvalsproducer change (domain:services), not made here, and not by readingorganizationIdas an alias.permissionsnow takes the user's resolved grants. Today such a hook falls back to the security plugin's member baseline, the service-automation: runAs:'user' runs data ops with a credential-less user — trigger context never carries the actor's permission sets/positions (follow-up to #1888) #3356 gap flows closed. The approvals' envelope carriespositions: []andpermissions: [], which stay verbatim by the same rule that keeps a narrowed ceiling, so those hooks keep the baseline. A separate gap, on the producer.systemPermissions,accessible_org_idsandposture. Read-only, not probed.ScopedContext. The seed is keyed on a module symbol (SEED_USER_GRANTS) that the package entry does not export; the built.d.tswas checked (fix round 1, report 6266560805). No new index symbol and no spec key:Clause-②: no.Generated by Claude Code