Skip to content

Commit 0ca8180

Browse 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>
1 parent 78b1a63 commit 0ca8180

6 files changed

Lines changed: 343 additions & 22 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
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.
17+
18+
Clause-②: no

‎packages/objectql/src/engine.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ import {
147147
// Layer 0 — see {@link buildDriverOptions}.
148148
computeTenantLayer0Verdict,
149149
// [#24497] The ONE grants resolver, the one the request path seeds a
150-
// context's grants and peer list through — see `ScopedContext[SEED_USER_GRANTS]`.
150+
// context's grants, peer list and email (#24639) through — see `ScopedContext[SEED_USER_GRANTS]`.
151151
resolveUserAuthzGrants,
152152
} from '@objectstack/core';
153153
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
@@ -21840,8 +21840,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2184021840

2184121841
/**
2184221842
* [#24497] The second half of a hook's `runAs: 'user'` derivation: the
21843-
* user's grants and peer list, seeded through the one resolver the request
21844-
* path seeds them through (`resolveUserAuthzGrants`, `@objectstack/core`).
21843+
* user's grants, peer list and email (#24639), seeded through the one
21844+
* resolver the request path seeds them through (`resolveUserAuthzGrants`,
21845+
* `@objectstack/core`).
2184521846
* The hook wrapper awaits it on the api {@link withRunAs} derived; it stays
2184621847
* a separate, async step so `withRunAs` keeps its synchronous contract.
2184721848
*
@@ -21852,13 +21853,31 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2185221853
* user's request saw their organisation's members too. Ruling A′ puts the
2185321854
* contract on the producer: every user context carries `org_user_ids`.
2185421855
*
21856+
* [#24639] The same contract holds for `email`: the RLS compiler binds
21857+
* `current_user.email` from the context's `email` and nothing else, so a
21858+
* hand-built context that named a user and carried no email made every
21859+
* email-keyed row policy (`assignee == current_user.email`) drop out, and
21860+
* the hook read nothing where the user's own request reads their rows. The
21861+
* email is the one the same resolution returns (the user's stored
21862+
* `sys_user.email`, from the user-row read that resolution already makes,
21863+
* as the request path falls back to it), so a context that resolves for its
21864+
* grants or peers pays nothing more for it. A context that lacks only the
21865+
* email pays the one resolution; a user with no stored email (a phone-only
21866+
* sign-up) pays it on every such dispatch and still carries none, which
21867+
* fails closed exactly as before.
21868+
*
2185521869
* The same rule as a `runAs: 'user'` flow's run setup (service-automation
2185621870
* `resolveRunContext`):
2185721871
* - positions and permission sets the context already carries stay
2185821872
* verbatim, so a narrowed ceiling is never re-broadened; only a context
2185921873
* with no `permissions` takes the resolved ones;
2186021874
* - a context that already carries a peer list keeps it;
21861-
* - a context that needs neither resolves nothing and is returned as is.
21875+
* - a context that already carries an email keeps it verbatim (the
21876+
* session's, on a request-resolved context); only a context with none
21877+
* takes the resolved one;
21878+
* - a context that needs none of the three resolves nothing and is
21879+
* returned as is. One that lacks only the email is NOT that context: it
21880+
* resolves, and takes the email alone.
2186221881
*
2186321882
* The peer list is the members of the context's `tenantId`, the user
2186421883
* included; a context with no `tenantId` gets the user alone, as an
@@ -21877,7 +21896,8 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2187721896
if (!ctx.userId) return this;
2187821897
const needsGrants = !Array.isArray(ctx.permissions);
2187921898
const needsPeers = !Array.isArray(ctx.org_user_ids);
21880-
if (!needsGrants && !needsPeers) return this;
21899+
const needsEmail = !ctx.email;
21900+
if (!needsGrants && !needsPeers && !needsEmail) return this;
2188121901
const grants = await resolveUserAuthzGrants(
2188221902
this.engine,
2188321903
ctx.userId,
@@ -21888,6 +21908,7 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2188821908
...ctx,
2188921909
...(needsGrants ? { positions: grants.positions, permissions: grants.permissions } : {}),
2189021910
...(needsPeers ? { org_user_ids: grants.org_user_ids } : {}),
21911+
...(needsEmail && grants.email ? { email: grants.email } : {}),
2189121912
},
2189221913
this.engine
2189321914
);

‎packages/objectql/src/hook-run-as.test.ts‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
HookUnscopedDataAccessError,
4242
HOOK_UNSCOPED_DATA_ACCESS_CODE,
4343
HOOK_UNSCOPED_DATA_ACCESS_STATUS,
44+
SEED_USER_GRANTS,
4445
hookRunAs,
4546
} from './hook-run-as.js';
4647
import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js';
@@ -62,6 +63,9 @@ const OPERATOR: ExecutionContext = {
6263
/** A trigger that resolved NO user: an `isSystem` service write, a system flow node. */
6364
const USERLESS: ExecutionContext = { isSystem: true, positions: [], permissions: [] } as ExecutionContext;
6465

66+
/** The email `usr_operator`'s stored `sys_user` row carries in the grants-answering engine below (#24639). */
67+
const STORED_EMAIL = 'operator@example.test';
68+
6569
// ---------------------------------------------------------------------------
6670
// Layer 1 — the wrapper over a real ScopedContext whose engine records.
6771
// ---------------------------------------------------------------------------
@@ -286,14 +290,18 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
286290
describe("[#24497] a 'user' pin carries the user's grants and peer list, through the request path's resolver", () => {
287291
/**
288292
* A recording engine that also answers the reads `resolveUserAuthzGrants`
289-
* issues: organisation `org_1` holds `usr_operator` and `usr_peer`.
293+
* issues: organisation `org_1` holds `usr_operator` and `usr_peer`, and
294+
* `usr_operator`'s stored `sys_user` row carries {@link STORED_EMAIL}.
290295
* `failReads` makes every read throw, as an unreadable permission store does.
291296
*/
292297
function grantsEngine(opts: { failReads?: boolean } = {}) {
293298
const engine = recordingEngine();
294299
engine.find = async (object: string, query: any) => {
295300
engine.seen.push({ method: 'find', context: query?.context, object, where: query?.where });
296301
if (opts.failReads) throw new Error('permission store unreachable');
302+
if (object === 'sys_user' && query?.where?.id === 'usr_operator') {
303+
return [{ id: 'usr_operator', email: STORED_EMAIL }];
304+
}
297305
if (object === 'sys_member' && query?.where?.organization_id === 'org_1') {
298306
return [
299307
{ id: 'm1', user_id: 'usr_operator', organization_id: 'org_1', role: 'member' },
@@ -340,12 +348,17 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
340348
});
341349

342350
it('a context that already carries a peer list (a request-resolved one) resolves NOTHING and keeps its envelope', async () => {
351+
// A request-resolved context carries the email the request path seeded
352+
// (`resolveAuthzContext`), so it needs nothing (#24639).
343353
const engine = grantsEngine();
344-
const context = await hookWrite({ ...OPERATOR, org_user_ids: ['usr_operator', 'usr_x'] } as ExecutionContext, engine);
354+
const context = await hookWrite({
355+
...OPERATOR, org_user_ids: ['usr_operator', 'usr_x'], email: 'session@example.test',
356+
} as ExecutionContext, engine);
345357
expect(reads(engine)).toEqual([]);
346358
expect(context.org_user_ids).toEqual(['usr_operator', 'usr_x']);
347359
expect(context.positions).toEqual(OPERATOR.positions);
348360
expect(context.permissions).toEqual(OPERATOR.permissions);
361+
expect(context.email).toBe('session@example.test');
349362
});
350363

351364
it('a trigger with no organisation: the peer list is the user alone, as an organisation-less request\'s is', async () => {
@@ -387,7 +400,48 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
387400
expect(context.isSystem).toBe(false);
388401
expect(context.org_user_ids).toBeUndefined();
389402
expect(warns).toHaveLength(1);
390-
expect(String(warns[0]![0])).toMatch(/current_user\.org_user_ids drops out/);
403+
// The variables whose policies drop out are named (#24639 adds the email).
404+
expect(String(warns[0]![0])).toMatch(/current_user\.org_user_ids/);
405+
expect(String(warns[0]![0])).toMatch(/current_user\.email/);
406+
});
407+
408+
describe("[#24639] the pin carries the user's email too, so a current_user.email row policy matches", () => {
409+
it("a hand-built { isSystem: true, userId, tenantId } trigger: the pin carries the email the user's sys_user row stores", async () => {
410+
const engine = grantsEngine();
411+
const context = await hookWrite({ isSystem: true, userId: 'usr_operator', tenantId: 'org_1' } as ExecutionContext, engine);
412+
expect(context.isSystem).toBe(false);
413+
expect(context.email).toBe(STORED_EMAIL);
414+
// The email rides the resolution the grants already make: ONE read of the user row.
415+
expect(reads(engine).filter((r: any) => r.object === 'sys_user')).toHaveLength(1);
416+
});
417+
418+
it('an email the context already carries is kept VERBATIM, never overwritten by the stored one', async () => {
419+
const engine = grantsEngine();
420+
const context = await hookWrite({
421+
isSystem: true, userId: 'usr_operator', tenantId: 'org_1', email: 'carried@example.test',
422+
} as ExecutionContext, engine);
423+
expect(context.email).toBe('carried@example.test');
424+
// It still needed its grants and peers, and took them.
425+
expect([...(context.org_user_ids ?? [])].sort()).toEqual(['usr_operator', 'usr_peer']);
426+
});
427+
428+
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 () => {
429+
const engine = grantsEngine();
430+
const context = await hookWrite({
431+
...OPERATOR, isSystem: true, org_user_ids: ['usr_operator', 'usr_x'],
432+
} as ExecutionContext, engine);
433+
expect(context.email).toBe(STORED_EMAIL);
434+
expect(context.org_user_ids).toEqual(['usr_operator', 'usr_x']);
435+
expect(context.positions).toEqual(OPERATOR.positions);
436+
expect(context.permissions).toEqual(OPERATOR.permissions);
437+
});
438+
439+
it('a context with no userId is returned untouched: nothing to seed, nothing read', async () => {
440+
const engine = grantsEngine();
441+
const api = new ScopedContext({ isSystem: false, tenantId: 'org_1' } as ExecutionContext, engine);
442+
expect(await api[SEED_USER_GRANTS]()).toBe(api);
443+
expect(reads(engine)).toEqual([]);
444+
});
391445
});
392446
});
393447

‎packages/objectql/src/hook-run-as.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ export function deriveHookApi(api: unknown, runAs: HookRunAs, ref: HookRunAsRef)
208208

209209
/**
210210
* The key the engine's `ScopedContext` carries the second half of a
211-
* `runAs: 'user'` derivation under (#24497): seed the user's grants and peer
212-
* list onto the api `withRunAs('user')` derived.
211+
* `runAs: 'user'` derivation under (#24497): seed the user's grants, peer
212+
* list and email (#24639) onto the api `withRunAs('user')` derived.
213213
*
214214
* A symbol, and ⛔ deliberately NOT re-exported from the package entry, so the
215215
* method is no NAMEABLE member of `ScopedContext`'s published face: only the
@@ -222,7 +222,7 @@ export const SEED_USER_GRANTS: unique symbol = Symbol('objectstack.objectql.hook
222222

223223
/**
224224
* What the api a `runAs: 'user'` derivation produced must offer for the
225-
* user's grants and peer list to be seeded onto it (#24497). The engine's
225+
* user's grants, peer list and email to be seeded onto it (#24497). The engine's
226226
* `ScopedContext` implements it (`engine.ts`, under {@link SEED_USER_GRANTS});
227227
* the refusing {@link UnscopedHookApi} does not, and needs nothing seeded.
228228
*/
@@ -231,9 +231,9 @@ interface UserGrantsSeedableApi {
231231
}
232232

233233
/**
234-
* Seed the user's grants and peer list onto the api a `runAs: 'user'`
234+
* Seed the user's grants, peer list and email onto the api a `runAs: 'user'`
235235
* derivation produced, through the core resolver the request path uses
236-
* (#24497). Async, so it is a step of its own after {@link deriveHookApi}
236+
* (#24497, #24639). Async, so it is a step of its own after {@link deriveHookApi}
237237
* rather than a change to that function's synchronous contract. An api that
238238
* cannot seed (the refusing {@link UnscopedHookApi}) is returned as is.
239239
*/

‎packages/objectql/src/hook-wrappers.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -557,22 +557,25 @@ function installRunAsApi(
557557
};
558558
};
559559
if (runAs !== 'user') return Promise.resolve(install(derived));
560-
// [#24497] A `'user'` pin carries the user's grants and peer list, seeded
561-
// through the resolver the request path uses (`ScopedContext[SEED_USER_GRANTS]`, `hook-run-as.ts`).
562-
// Cost: one `resolveUserAuthzGrants` per dispatch whose triggering context
563-
// carries no `permissions` or no `org_user_ids` — the hand-built system
564-
// contexts; a request-resolved context carries both and resolves nothing.
560+
// [#24497] A `'user'` pin carries the user's grants, peer list and email
561+
// (#24639), seeded through the resolver the request path uses
562+
// (`ScopedContext[SEED_USER_GRANTS]`, `hook-run-as.ts`). Cost: one
563+
// `resolveUserAuthzGrants` per dispatch whose triggering context carries no
564+
// `permissions`, no `org_user_ids` or no `email` — the hand-built system
565+
// contexts; a request-resolved context carries all three and resolves
566+
// nothing (unless its user stores no email: then every such dispatch resolves).
565567
return seedHookUserGrants(derived).then(install, (err: unknown) => {
566568
// Fail-safe, never fail-open — the flow run setup's posture: the hook keeps
567569
// the unseeded pin, so its data ops run on the context's own grants (the
568570
// security plugin's member baseline when it carries none, never elevation),
569-
// and every row policy reading `current_user.org_user_ids` drops out. It
570-
// sees less, never more. FUNCTIONAL, so `warn`: nothing claimed-persisted
571+
// and every row policy reading a value it does not carry
572+
// (`current_user.org_user_ids`, `current_user.email`) drops out. It sees
573+
// less, never more. FUNCTIONAL, so `warn`: nothing claimed-persisted
571574
// failed to land, and the symptom is the hook's own narrower reads.
572575
logger.warn(
573576
`[hook] runAs 'user': the triggering user's grants could not be resolved, so this hook's data ops run ` +
574-
`on the triggering context's own grants (not elevated), and every row policy reading ` +
575-
`current_user.org_user_ids drops out`,
577+
`on the triggering context's own grants (not elevated), and every row policy reading a value that ` +
578+
`context does not carry (current_user.email, current_user.org_user_ids) drops out`,
576579
{ hook: hookName, object: ctx.object, event: ctx.event, error: (err as any)?.message },
577580
);
578581
return install(derived);

0 commit comments

Comments
 (0)