You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0ca8180
Browse filesBrowse the repository at this point in the historyBrowse files
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)
Fixes#24639
Clause-②: no (a runtime producer change: a `runAs: 'user'` hook reads
what the same user's own request reads. Nothing `@objectstack/spec`
declares 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's `runAs: 'user'`
derivation. It seeded `positions` / `permissions` and `org_user_ids`
from `resolveUserAuthzGrants`, and never `email`, although that same
resolution returns `grants.email` (the user's stored `sys_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.email` from the context's `email`
and 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, a `runAs: 'system'` flow's writes) read
nothing under a `select` policy `assignee == 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's `runas-user-hook-org-peers` probe and its flow twin
`runas-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 is `assignee == current_user.email`. A `runAs:
'user'` `afterInsert` hook reads the task object through `ctx.api`, and
a middleware records the context that read carries. `usr_member` and
`usr_other` each 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 at `f89d29e382`. The after column is head `841da88b8c`.
| triggering context | before: rows / hook context email | after: rows /
hook context email |
|---|---|---|
| CONTROL: the member's own request (and the GUARD: the same request
without its email) | `member task` (GUARD: none) | same |
| the card's shape `{ isSystem: true, userId, tenantId }` | none /
absent | `member task` / the stored email |
| the request-resolved envelope, email included (control) | `member
task` / verbatim | `member task` / envelope verbatim, nothing resolved |
| a carried email that differs from the stored one | `other task` / the
carried one | `other task` / the carried one, verbatim |
| positions, permission sets and peer list carried, no email (the
early-return case) | none / absent | `member task` / the stored email;
positions, sets and peer list verbatim |
## The change
- **`ScopedContext[SEED_USER_GRANTS]` (`engine.ts`)** adds `needsEmail =
!ctx.email` beside `needsGrants` / `needsPeers`, includes it in the
early-return test, and seeds `email: grants.email` only when the context
carries none. This is PR #24518's rule extended to the email, as triage
set it:
- a carried email is kept verbatim (on a request-resolved context, the
session's);
- a context with no `userId` is still returned untouched;
- the email comes from the same single resolution, so a context that
resolves for its grants or peers pays nothing more. There is no second
read and no second resolver.
- **`SEED_USER_GRANTS`** stays a symbol the package entry does not
export. No new member, export or type.
- ⛔ No backfill in `plugin-security`; `rls-compiler.ts` is untouched
(ruling A′, `6261536443` item 2).
**Where the files landed vs the claim's fence.** `engine.ts` (the seed
only, plus its import comment) and `hook-run-as.ts` (the seed's contract
text, three docblocks) are in the fence. One file is outside it:
`hook-wrappers.ts`. Its `installRunAsApi` comment carries the seed's
cost contract ("a request-resolved context carries both and resolves
nothing"), and its resolver-failure `warn` said every policy reading
`current_user.org_user_ids` drops 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 in `service-automation` for the reason PR #24518 gives: `objectql`
cannot depend on driver-sql, plugin-auth or plugin-security.
**Cost.** A `runAs: 'user'` hook whose triggering context lacks the
email now pays one `resolveUserAuthzGrants` per 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 real `ScopedContext` over a recording engine that
answers the resolver's reads; its `sys_user` row now carries an email):
- the hand-built shape carries the stored email, with ONE `sys_user`
read;
- a carried email stays verbatim while grants and peers are still taken;
- a context with permissions and a peer list but no email is not
returned early: it takes the email alone, the rest verbatim;
- a context with no `userId` is returned by reference, nothing read.
- **Moved pins, two:**
- `a context that already carries a peer list (a request-resolved one)
resolves NOTHING` used `OPERATOR` plus 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.
- the resolver-failure pin matched the warn's sentence
`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.
- **The real-stack probe above** (5 cases).
## Ablation (committed first, mutated through
`scripts/ablation-replace.mjs`)
- **Mutation:** delete the one seeding line, `...(needsEmail &&
grants.email ? { email: grants.email } : {}),` (anchor 1 to 0; blob
`a2c16cc04b` to `48e99778a5`). `objectql` rebuilt;
`ablation-dist-preflight.mjs @objectstack/objectql 'email: grants.email'
--absent`: marker absent from all 14 built files.
- **Unit:** 2 red, 23 green: the hand-built shape and the early-return
case (`expected undefined to be 'operator@example.test'`).
- **Integration:** 2 red, 3 green: the card's shape (`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.
- **Restore:** blob equal to HEAD (`a2c16cc04b`), `git diff HEAD` empty,
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).
- objectql, the full `local` project in three shards
(`--shard=1/3..3/3`): 448 files, 8451 tests passed (2738 + 2809 + 2904).
- service-automation `runas-*`, `crud-runas` and
`resume-decider-identity`: 9 files, 93 tests passed.
- plugin-approvals `status-mirror-envelope` and `status-mirror-cascade`
(the named producer): 2 files, 12 tests passed.
- **Derived gates** (`node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, no paths, 72 commands): all 72 exit
0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`,
unbuilt packages); after a full `turbo run build` it exited 0.
`check:engine-split-ratio` ran on a history deepened with `git 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:repo` was not run locally: no test of that project is touched
and the derivation names none; `Test Core` runs it.
## Acceptance notes
- **`origin/main` moved during this run** (`f89d29e382` to `bfc1539fe0`,
four commits). None touches `objectql`, the hook path or the resolver;
the only overlap by package is a one-line help string in
`service-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.
- **The unreleased PR #24518 changeset**
(`.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](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql): a `runAs: 'user'` hook now carries the acting user's email, so a `current_user.email` row policy admits it as it admits the same user's own request
6
+
7
+
A hook declaring `runAs: 'user'` is pinned to the triggering user, and the pinned context is seeded with the user's grants and organization peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`). That seed never took the user's email, although the same resolution returns it. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals status mirror and a `runAs: 'system'` flow's writes are shipped producers), the pinned context carried no `email`. Every row policy keyed on `current_user.email` (for example `assignee == current_user.email`) therefore dropped out, and the hook's `ctx.api` reads returned no rows where the same user's request returns theirs. It failed closed: rows were hidden, never leaked.
8
+
9
+
The seed now carries the email too:
10
+
11
+
- A pinned context with no `email` takes the user's stored email (`sys_user.email`), from the user-row read the resolution already makes, which is the fallback the request path uses.
12
+
- An email the context already carries, such as a session's on a request-resolved context, is kept exactly as it was.
13
+
- A context that carries `permissions` and a peer list but no email is no longer handed through unseeded: it resolves once and takes the email alone. Its positions, permission sets and peer list stay exactly as they were.
14
+
- A user with no stored email (a phone-only sign-up) still carries none, and email-keyed policies still drop out for them, as on their own request.
15
+
16
+
`runAs: 'inherit'` (the default) and `runAs: 'system'` are unchanged, and a `runAs: 'user'` hook whose trigger names no user is still refused with `HOOK_UNSCOPED_DATA_ACCESS`. A `runAs: 'user'` hook fired from a context that lacks the email now pays one grants resolution per dispatch, as one that lacks either list already did. If the resolver fails, the hook runs on the unseeded pin and a warning names the policies that drop out.
it('a context that carries positions, permission sets and a peer list but NO email is not returned early: it takes the email, and only the email',async()=>{
0 commit comments