Repository navigation
fix(objectql): a runAs:'user' hook carries its user's email, so a current_user.email row policy admits it as it admits the user's own request - #24674
Conversation
…he fix) The real-stack probe and the unit pins for a hook fired from a hand-built context that names a user and carries no email. Committed alone so the before column is read against unchanged source. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
… a current_user.email row policy matches as on the user's own request
ScopedContext[SEED_USER_GRANTS] seeded positions, permission sets and the
peer list from resolveUserAuthzGrants and never the email that same
resolution returns, and its early return also skipped a context that lacked
only the email. A hook fired from a hand-built { isSystem: true, userId,
tenantId } context therefore read nothing under an email-keyed select policy.
The seed now takes grants.email where the context carries none, keeps a
carried email verbatim, and no longer returns early on a context that lacks
only the email.
Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD 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 90e5a36c71a2269f0f311b09b157286951af061c && git checkout 90e5a36c71a2269f0f311b09b157286951af061c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bfc1539fe00a33bc3215614a8d6c76a05eb8e8aa 841da88b8c73a0543f73c075436bcc73866140c0 && git checkout -B drift-repro bfc1539fe00a33bc3215614a8d6c76a05eb8e8aa && git merge --no-ff 841da88b8c73a0543f73c075436bcc73866140c0
node scripts/docs-audit/affected-docs.mjs --json bfc1539fe00a33bc3215614a8d6c76a05eb8e8aa
|
席位核对 · ACCEPT ·
|
Fixes #24639
Clause-②: no (a runtime producer change: a
runAs: 'user'hook reads what the same user's own request reads. Nothing@objectstack/specdeclares moves; no export is added or removed)What was wrong
ScopedContext[SEED_USER_GRANTS](packages/objectql/src/engine.ts, added by PR #24518) is the second half of a hook'srunAs: 'user'derivation. It seededpositions/permissionsandorg_user_idsfromresolveUserAuthzGrants, and neveremail, although that same resolution returnsgrants.email(the user's storedsys_user.email, from the user-row read it already makes). Its early return (if (!needsGrants && !needsPeers) return this;) also returned a context unchanged when only its email was missing.The RLS compiler binds
current_user.emailfrom the context'semailand nothing else (plugin-security/src/rls-compiler.ts). So a hook fired from a hand-built{ isSystem: true, userId, tenantId }context (the approvals status mirror, arunAs: 'system'flow's writes) read nothing under aselectpolicyassignee == current_user.email, while the same user's request read their row. It failed closed: rows hidden, never leaked.Measurement (real stack, before and after)
The probe is
packages/services/service-automation/src/runas-user-hook-email-owner.integration.test.ts, beside PR #24518'srunas-user-hook-org-peersprobe and its flow twinrunas-user-email-owner. It boots ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3:memory:, the identity objects and the real SecurityPlugin with its shipped default sets plus a fixture set whose only row scope isassignee == current_user.email. ArunAs: 'user'afterInserthook reads the task object throughctx.api, and a middleware records the context that read carries.usr_memberandusr_othereach hold the set and each have one task assigned to their stored email.The probe was committed alone first (
f7366e4a5c) and run against unchanged source atf89d29e382. The after column is head841da88b8c.member task(GUARD: none){ isSystem: true, userId, tenantId }member task/ the stored emailmember task/ verbatimmember task/ envelope verbatim, nothing resolvedother task/ the carried oneother task/ the carried one, verbatimmember task/ the stored email; positions, sets and peer list verbatimThe change
ScopedContext[SEED_USER_GRANTS](engine.ts) addsneedsEmail = !ctx.emailbesideneedsGrants/needsPeers, includes it in the early-return test, and seedsemail: grants.emailonly when the context carries none. This is PR fix(objectql): a runAs:'user' hook sees its organization's members in sys_user, as the user's own request does #24518's rule extended to the email, as triage set it:userIdis still returned untouched;SEED_USER_GRANTSstays a symbol the package entry does not export. No new member, export or type.plugin-security;rls-compiler.tsis untouched (ruling A′,6261536443item 2).Where the files landed vs the claim's fence.
engine.ts(the seed only, plus its import comment) andhook-run-as.ts(the seed's contract text, three docblocks) are in the fence. One file is outside it:hook-wrappers.ts. ItsinstallRunAsApicomment carries the seed's cost contract ("a request-resolved context carries both and resolves nothing"), and its resolver-failurewarnsaid every policy readingcurrent_user.org_user_idsdrops out. This change makes both false for a context that lacks only the email, so the comment and the warn's wording were brought in line. No logic there moves. The real-stack probe lives inservice-automationfor the reason PR #24518 gives:objectqlcannot depend on driver-sql, plugin-auth or plugin-security.Cost. A
runAs: 'user'hook whose triggering context lacks the email now pays oneresolveUserAuthzGrantsper dispatch, as one that lacks either list already did. A request-resolved context carries an email and pays nothing, unless its user stores none (a phone-only sign-up): then every such dispatch resolves and the context still carries no email, which fails closed as before.Pins
hook-run-as.test.ts, new[#24639]block inside the[#24497]one (layer 1, a realScopedContextover a recording engine that answers the resolver's reads; itssys_userrow now carries an email):sys_userread;userIdis returned by reference, nothing read.a context that already carries a peer list (a request-resolved one) resolves NOTHINGusedOPERATORplus a peer list, which is not a request-resolved context: the request path seeds an email. The fixture now carries one (a fixture that lacked a declaration, re-judged rather than re-spelled), and the pin also asserts the email stays verbatim.current_user.org_user_ids drops out. It now asserts the two variables the warn names (current_user.org_user_ids,current_user.email), not a sentence.Ablation (committed first, mutated through
scripts/ablation-replace.mjs)...(needsEmail && grants.email ? { email: grants.email } : {}),(anchor 1 to 0; bloba2c16cc04bto48e99778a5).objectqlrebuilt;ablation-dist-preflight.mjs @objectstack/objectql 'email: grants.email' --absent: marker absent from all 14 built files.expected undefined to be 'operator@example.test').expected [] to deeply equal [ 'member task' ]) and the early-return case (expected undefined to be 'qa-hook-member@example.test'). The two controls and the carried-email pin stay green.a2c16cc04b),git diff HEADempty, whole tree clean, rebuilt, marker present again in 4 built files. 25/25 and 5/5 green.Tests and gates (head
841da88b8c)pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck: OK, debt held at 39 files / 233 errors / 64 signatures).pnpm --filter @objectstack/service-automation typecheck: exit 0 (0 / 0 / 0).localproject in three shards (--shard=1/3..3/3): 448 files, 8451 tests passed (2738 + 2809 + 2904).runas-*,crud-runasandresume-decider-identity: 9 files, 93 tests passed.status-mirror-envelopeandstatus-mirror-cascade(the named producer): 2 files, 12 tests passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, 72 commands): all 72 exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, unbuilt packages); after a fullturbo run buildit exited 0.check:engine-split-ratioran on a history deepened withgit fetch --shallow-since=2026-07-14 origin main.--ran:72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED (a DERIVED zero — all 72 recorded an exit code and none of them is 3).test:repowas not run locally: no test of that project is touched and the derivation names none;Test Coreruns it.Acceptance notes
origin/mainmoved during this run (f89d29e382tobfc1539fe0, four commits). None touchesobjectql, the hook path or the resolver; the only overlap by package is a one-line help string inservice-automation/src/builtin/try-catch-node.ts. The branch was not merged; this PR's CI merge ref and the queue validate the joint tree..changeset/24497-hook-runas-org-peers.md) says a request-resolved context resolves nothing. That stays true for a user with a stored email; this PR's changeset states the email rule, and both ship in the same release. The older changeset is not edited here.Generated by Claude Code