From 1f1afac0857ef103d998e64e4df49e431958f0aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:31:44 +0000 Subject: [PATCH 1/6] =?UTF-8?q?test(service-automation):=20real-stack=20pr?= =?UTF-8?q?obe=20=E2=80=94=20a=20runAs:'user'=20hook's=20sys=5Fuser=20read?= =?UTF-8?q?=20from=20a=20hand-built=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- ...as-user-hook-org-peers.integration.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts diff --git a/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts new file mode 100644 index 00000000000..d18cb7b2588 --- /dev/null +++ b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `runAs:'user'` HOOK reads `sys_user` exactly as the triggering user's own + * request does: the acting user and the members of the organisation they act + * in, and nothing beyond. The hook-side twin of + * `runas-user-org-peers.integration.test.ts` (the flow side). + * + * The defect this pins: a hook fired from a hand-built + * `{ isSystem: true, userId, tenantId }` context (the approvals status mirror + * and decision writes are the shipped producers) is de-elevated by + * `ScopedContext.withRunAs('user')` to `{ ...context, isSystem: false }`. That + * envelope carries no `org_user_ids`, so `sys_user_org_members` + * (`id in current_user.org_user_ids`) compiled `unresolved-variable` and + * dropped out: the hook saw only the acting user's own row. The engine now + * seeds the peer list, through the core resolver the request path uses + * (`resolveUserAuthzGrants`), where a hook's `runAs:'user'` is resolved. + * + * It lives here, beside its flow twin, because this package is the one that + * already composes the whole stack under test in its dev dependencies: the + * ObjectQL engine, driver-sql on better-sqlite3 `:memory:`, the identity + * objects and the real SecurityPlugin with its shipped default permission + * sets. The defect sits in the hand-off between the hook's identity and the + * RLS compiler, which no single-layer test sees. No automation runs here. + * + * Fixture: organisation A (the Default Organization) and organisation B. + * `usr_me` belongs to BOTH and acts in A; `usr_peer` belongs to A; + * `usr_other` belongs to B only; `usr_loner` belongs to none. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, bindHooksToEngine, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin } from '@objectstack/plugin-security'; + +const SYS = { isSystem: true } as const; +const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + +/** The object whose insert fires the hook under test. */ +const probeObject = { + name: 'hook_probe', + label: 'Hook probe', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +describe("a runAs:'user' hook sees the acting user and their organisation's members, and nothing beyond", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let orgA = ''; + const orgB = 'org_b'; + /** What the hook's `sys_user` read answered, per firing. */ + let seen: string[][] = []; + /** The context the hook's `sys_user` read carried to the middleware chain, per firing. */ + let seenContexts: Array> = []; + + afterEach(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + async function boot() { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin()); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + ql.registry.registerObject(probeObject as any); + await (ql as any).syncObjectSchema?.('hook_probe'); + + const orgs = await ql.find('sys_organization', { context: SYS }); + expect(orgs.map((o: any) => o.slug)).toEqual(['default']); + orgA = String(orgs[0].id); + await ql.insert('sys_organization', { id: orgB, name: 'Organisation B', slug: 'org-b' }, { context: SYS }); + + for (const [id, name] of [['usr_me', 'Me'], ['usr_peer', 'Peer'], ['usr_other', 'Other'], ['usr_loner', 'Loner']]) { + await ql.insert('sys_user', { id, name, email: `${id}@example.test` }, { context: SYS }); + } + const memberships: Array<[string, string, string]> = [ + ['mem_me_a', 'usr_me', orgA], + ['mem_me_b', 'usr_me', orgB], + ['mem_peer_a', 'usr_peer', orgA], + ['mem_other_b', 'usr_other', orgB], + ]; + for (const [id, userId, organizationId] of memberships) { + await ql.insert('sys_member', { id, user_id: userId, organization_id: organizationId, role: 'member' }, { context: SYS }); + } + + seen = []; + seenContexts = []; + let inHook = false; + ql.registerMiddleware(async (opCtx: any, next: any) => { + if (inHook && opCtx.object === 'sys_user') seenContexts.push({ ...(opCtx.context ?? {}) }); + return next(); + }); + bindHooksToEngine(ql, [ + { + name: 'read_directory', + object: 'hook_probe', + events: ['afterInsert'], + priority: 100, + runAs: 'user', + handler: async (ctx: any) => { + inHook = true; + try { + const rows = await ctx.api.object('sys_user').find({ fields: ['id', 'name'] }); + seen.push(ids(rows)); + } finally { + inHook = false; + } + }, + } as any, + ], { packageId: 'app:test', logger: silentLogger }); + } + + /** + * The context the request path hands ObjectQL for `userId` acting in + * `tenantId`: `resolveAuthzContext` copies exactly these grants onto it. + */ + async function requestContext(userId: string, tenantId?: string) { + const grants = await resolveUserAuthzGrants(ql, userId, { tenantId, bypassGrantsCache: true }); + return { + userId, + ...(tenantId ? { tenantId } : {}), + isSystem: false, + positions: grants.positions, + permissions: grants.permissions, + systemPermissions: grants.systemPermissions, + org_user_ids: grants.org_user_ids, + accessible_org_ids: grants.accessible_org_ids, + }; + } + + const ids = (rows: unknown): string[] => (Array.isArray(rows) ? rows.map((r: any) => String(r.id)).sort() : []); + + /** Fire the hook with `context` as the triggering write's context; answer what its `sys_user` read saw. */ + async function fire(context: Record) { + const before = seen.length; + await ql.insert('hook_probe', { name: 'p' }, { context: context as any }); + expect(seen.length, 'the hook ran exactly once').toBe(before + 1); + return seen[seen.length - 1]; + } + + it("CONTROL — the user's own request sees themself and organisation A's members, and nothing beyond", async () => { + await boot(); + const rows = await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me', orgA) }); + expect(ids(rows)).toEqual(['usr_me', 'usr_peer']); + }); + + it('fired from the hand-built approvals shape ({ isSystem: true, userId, tenantId }), the hook answers what the request answers', async () => { + await boot(); + expect(await fire({ isSystem: true, userId: 'usr_me', tenantId: orgA })).toEqual(['usr_me', 'usr_peer']); + }); + + it('fired from a request-resolved context, the hook answers the same and keeps the envelope it was handed (control)', async () => { + await boot(); + const req = await requestContext('usr_me', orgA); + expect(await fire(req)).toEqual(['usr_me', 'usr_peer']); + const hookCtx = seenContexts[seenContexts.length - 1]; + expect(hookCtx.org_user_ids).toEqual(req.org_user_ids); + expect(hookCtx.positions).toEqual(req.positions); + expect(hookCtx.permissions).toEqual(req.permissions); + }); + + it('fired with no organisation, the hook answers what an organisation-less request answers: the acting user alone', async () => { + await boot(); + const rows = await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me') }); + expect(ids(rows)).toEqual(['usr_me']); + expect(await fire({ isSystem: true, userId: 'usr_me' })).toEqual(['usr_me']); + }); +}); From e8b0d3480b51d861af5d41567fea8fdf60826a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:41:35 +0000 Subject: [PATCH 2/6] fix(objectql): a runAs:'user' hook's pin carries the user's grants and peer list, seeded through the request path's resolver Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- packages/objectql/src/engine.ts | 51 +++++++++ packages/objectql/src/hook-run-as.test.ts | 105 +++++++++++++++++- packages/objectql/src/hook-run-as.ts | 23 ++++ packages/objectql/src/hook-wrappers.ts | 50 +++++++-- ...as-user-hook-org-peers.integration.test.ts | 24 +++- 5 files changed, 241 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 53961e58c5a..9a6147704c9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -146,6 +146,9 @@ import { // [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's // Layer 0 — see {@link buildDriverOptions}. computeTenantLayer0Verdict, + // [#24497] The ONE grants resolver, the one the request path seeds a + // context's grants and peer list through — see `ScopedContext.withUserGrantsSeeded`. + resolveUserAuthzGrants, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; @@ -21776,6 +21779,54 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { ); } + /** + * [#24497] The second half of a hook's `runAs: 'user'` derivation: the + * user's grants and peer list, seeded through the one resolver the request + * path seeds them through (`resolveUserAuthzGrants`, `@objectstack/core`). + * The hook wrapper awaits it on the api {@link withRunAs} derived; it stays + * a separate, async step so `withRunAs` keeps its synchronous contract. + * + * A hook fired from a hand-built `{ isSystem: true, userId }` context (the + * approvals writes) was pinned with no `org_user_ids`, so every row policy + * reading `current_user.org_user_ids` compiled `unresolved-variable` and + * dropped out: the hook saw its own `sys_user` row only, while the same + * user's request saw their organisation's members too. Ruling A′ puts the + * contract on the producer: every user context carries `org_user_ids`. + * + * The same rule as a `runAs: 'user'` flow's run setup (service-automation + * `resolveRunContext`): + * - positions and permission sets the context already carries stay + * verbatim, so a narrowed ceiling is never re-broadened; only a context + * with no `permissions` takes the resolved ones; + * - a context that already carries a peer list keeps it; + * - a context that needs neither resolves nothing and is returned as is. + * + * The peer list is the members of the context's `tenantId`, the user + * included; a context with no `tenantId` gets the user alone, as an + * organisation-less request does. A resolver failure (an unreadable + * permission store) is thrown to the caller, which keeps the unseeded api. + */ + async withUserGrantsSeeded(): Promise { + const ctx = this.executionContext; + if (ctx.isSystem || !ctx.userId) return this; + const needsGrants = !Array.isArray(ctx.permissions); + const needsPeers = !Array.isArray(ctx.org_user_ids); + if (!needsGrants && !needsPeers) return this; + const grants = await resolveUserAuthzGrants( + this.engine, + ctx.userId, + ctx.tenantId ? { tenantId: ctx.tenantId } : {}, + ); + return new ScopedContext( + { + ...ctx, + ...(needsGrants ? { positions: grants.positions, permissions: grants.permissions } : {}), + ...(needsPeers ? { org_user_ids: grants.org_user_ids } : {}), + }, + this.engine + ); + } + /** * Execute a callback within a database transaction. * diff --git a/packages/objectql/src/hook-run-as.test.ts b/packages/objectql/src/hook-run-as.test.ts index 3fc784ae8e4..6d32f97345b 100644 --- a/packages/objectql/src/hook-run-as.test.ts +++ b/packages/objectql/src/hook-run-as.test.ts @@ -182,7 +182,9 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => }, { logger: silentLogger }); await wrapped(ctx); - const context = engine.seen[0]!.context as ExecutionContext; + // The hook's own write, not the grants resolution the pin now issues + // first (#24497, pinned in its own block below). + const context = engine.seen.find((e: { method: string }) => e.method === 'update')!.context as ExecutionContext; expect(context.isSystem).toBe(false); expect(context.userId).toBe('usr_operator'); }); @@ -281,6 +283,107 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => }); }); + describe("[#24497] a 'user' pin carries the user's grants and peer list, through the request path's resolver", () => { + /** + * A recording engine that also answers the reads `resolveUserAuthzGrants` + * issues: organisation `org_1` holds `usr_operator` and `usr_peer`. + * `failReads` makes every read throw, as an unreadable permission store does. + */ + function grantsEngine(opts: { failReads?: boolean } = {}) { + const engine = recordingEngine(); + engine.find = async (object: string, query: any) => { + engine.seen.push({ method: 'find', context: query?.context, object, where: query?.where }); + if (opts.failReads) throw new Error('permission store unreachable'); + if (object === 'sys_member' && query?.where?.organization_id === 'org_1') { + return [ + { id: 'm1', user_id: 'usr_operator', organization_id: 'org_1', role: 'member' }, + { id: 'm2', user_id: 'usr_peer', organization_id: 'org_1', role: 'member' }, + ]; + } + return []; + }; + return engine; + } + + async function hookWrite(execCtx: ExecutionContext, engine: any, runAs = 'user', logger: any = silentLogger) { + const wrapped = wrapDeclarativeHook(hookOf(runAs), async (c) => { + await (c.api as any).object('account').update({ id: 'acct_1', current_grade: 'A' }); + }, { logger }); + await wrapped(makeCtx(execCtx, engine)); + return engine.seen.find((e: { method: string }) => e.method === 'update')!.context as ExecutionContext; + } + + const reads = (engine: any) => engine.seen.filter((e: { method: string }) => e.method === 'find'); + + it('a hand-built { isSystem: true, userId, tenantId } trigger: the pin carries the organisation\'s peers, the user included, and nothing beyond', async () => { + const engine = grantsEngine(); + const context = await hookWrite({ isSystem: true, userId: 'usr_operator', tenantId: 'org_1' } as ExecutionContext, engine); + expect(context.isSystem).toBe(false); + expect([...(context.org_user_ids ?? [])].sort()).toEqual(['usr_operator', 'usr_peer']); + // Carried no permissions, so it takes the resolved ones (an array, as on a request). + expect(Array.isArray(context.permissions)).toBe(true); + expect(Array.isArray(context.positions)).toBe(true); + // The peer read is scoped to the context's organisation. + expect(reads(engine).some((r: any) => r.object === 'sys_member' && r.where?.organization_id === 'org_1')).toBe(true); + }); + + it('positions and permission sets already on the context stay VERBATIM; only the peer list is taken', async () => { + // The approvals writes' shape: `{ isSystem: true, positions: [], permissions: [], userId }` — + // and a narrowed ceiling a door forwarded is the same case: never re-broadened. + const engine = grantsEngine(); + const context = await hookWrite({ + isSystem: true, userId: 'usr_operator', tenantId: 'org_1', positions: ['narrow'], permissions: ['ceiling_only'], + } as ExecutionContext, engine); + expect(context.positions).toEqual(['narrow']); + expect(context.permissions).toEqual(['ceiling_only']); + expect([...(context.org_user_ids ?? [])].sort()).toEqual(['usr_operator', 'usr_peer']); + }); + + it('a context that already carries a peer list (a request-resolved one) resolves NOTHING and keeps its envelope', async () => { + const engine = grantsEngine(); + const context = await hookWrite({ ...OPERATOR, org_user_ids: ['usr_operator', 'usr_x'] } as ExecutionContext, engine); + expect(reads(engine)).toEqual([]); + expect(context.org_user_ids).toEqual(['usr_operator', 'usr_x']); + expect(context.positions).toEqual(OPERATOR.positions); + expect(context.permissions).toEqual(OPERATOR.permissions); + }); + + it('a trigger with no organisation: the peer list is the user alone, as an organisation-less request\'s is', async () => { + const engine = grantsEngine(); + const context = await hookWrite({ isSystem: true, userId: 'usr_operator' } as ExecutionContext, engine); + expect(context.org_user_ids).toEqual(['usr_operator']); + }); + + it("'system' and 'inherit' resolve nothing, and the user-less refusal does not move", async () => { + const sys = grantsEngine(); + expect((await hookWrite({ isSystem: true, userId: 'usr_operator', tenantId: 'org_1' } as ExecutionContext, sys, 'system')).isSystem).toBe(true); + expect(reads(sys)).toEqual([]); + + const inherit = grantsEngine(); + await hookWrite(OPERATOR, inherit, 'inherit'); + expect(reads(inherit)).toEqual([]); + + const userless = grantsEngine(); + const ctx = makeCtx(USERLESS, userless); + const wrapped = wrapDeclarativeHook(hookOf('user'), async (c) => { + await (c.api as any).object('account').update({ id: 'acct_1', current_grade: 'A' }); + }, { logger: silentLogger }); + await expect(wrapped(ctx)).rejects.toMatchObject({ code: HOOK_UNSCOPED_DATA_ACCESS_CODE, status: HOOK_UNSCOPED_DATA_ACCESS_STATUS }); + expect(userless.seen).toEqual([]); + }); + + it('a resolver failure keeps the UNSEEDED pin — narrower, never elevated — and says so once at warn', async () => { + const engine = grantsEngine({ failReads: true }); + const warns: unknown[][] = []; + const logger = { ...silentLogger, warn: (...a: unknown[]) => { warns.push(a); } }; + const context = await hookWrite({ isSystem: true, userId: 'usr_operator', tenantId: 'org_1' } as ExecutionContext, engine, 'user', logger); + expect(context.isSystem).toBe(false); + expect(context.org_user_ids).toBeUndefined(); + expect(warns).toHaveLength(1); + expect(String(warns[0]![0])).toMatch(/current_user\.org_user_ids drops out/); + }); + }); + it('a runAs value outside the enum is refused LOUDLY at bind time, never silently ignored', () => { // Declared ≠ enforced is the defect this key exists to end, so a hook that // reached the engine without going through `HookSchema` cannot declare an diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index 26d796cde2d..9f5c4ae921b 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -205,3 +205,26 @@ export function deriveHookApi(api: unknown, runAs: HookRunAs, ref: HookRunAsRef) } return derivable.withRunAs(runAs, ref); } + +/** + * What the api a `runAs: 'user'` derivation produced must offer for the + * user's grants and peer list to be seeded onto it (#24497). The engine's + * `ScopedContext` implements it (`engine.ts` `withUserGrantsSeeded`); the + * refusing {@link UnscopedHookApi} does not, and needs nothing seeded. + */ +interface UserGrantsSeedableApi { + withUserGrantsSeeded(): Promise; +} + +/** + * Seed the user's grants and peer list onto the api a `runAs: 'user'` + * derivation produced, through the core resolver the request path uses + * (#24497). Async, so it is a step of its own after {@link deriveHookApi} + * rather than a change to that function's synchronous contract. An api that + * cannot seed (the refusing {@link UnscopedHookApi}) is returned as is. + */ +export async function seedHookUserGrants(api: unknown): Promise { + const seedable = api as Partial | undefined; + if (!seedable || typeof seedable.withUserGrantsSeeded !== 'function') return api; + return seedable.withUserGrantsSeeded(); +} diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index fb6056cc756..b37ee8c33bc 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -23,7 +23,7 @@ import { noopHookMetricsRecorder, type HookMetricsRecorder, type HookMetricOutco import { materializeDeclaredFields } from './declared-fields.js'; import { describeCelFault, type CelFault } from './cel-fault.js'; import { nullPreviousReadDetail, ruleScope, type PreviousBinding } from './validation/rule-validator.js'; -import { hookRunAs, deriveHookApi, type HookRunAs } from './hook-run-as.js'; +import { hookRunAs, deriveHookApi, seedHookUserGrants, type HookRunAs } from './hook-run-as.js'; /** * The logger the hook layer writes its diagnostics to — the `Logger` CONTRACT @@ -469,8 +469,8 @@ export function wrapDeclarativeHook( // engine's own context object is never touched (the condition gate above // already ran against it; the readonly strip and the session keep reading // it). Under `'inherit'` this is a no-op that leaves the engine-built api - // in place by reference. - const restoreApi = installRunAsApi(ctx, runAs, meta.name); + // in place by reference — and, deliberately, without an extra `await`. + const restoreApi = runAs === 'inherit' ? noRestore : await installRunAsApi(ctx, runAs, meta.name, logger); const startedAt = Date.now(); const recordOutcome = (err?: any) => { @@ -537,19 +537,51 @@ export function wrapDeclarativeHook( * only the api holds the full triggering `ExecutionContext` — `ctx.session` is * a projection of it and drops the transaction handle, among other things. */ -function installRunAsApi(ctx: HookContext, runAs: HookRunAs, hookName: string): () => void { - if (runAs === 'inherit') return () => {}; +function installRunAsApi( + ctx: HookContext, + runAs: HookRunAs, + hookName: string, + logger: HookDiagnosticsLogger, +): Promise<() => void> { + if (runAs === 'inherit') return Promise.resolve(noRestore); const original = ctx.api; - ctx.api = deriveHookApi(original, runAs, { + const derived = deriveHookApi(original, runAs, { hook: hookName, object: ctx.object, event: ctx.event, - }) as HookContext['api']; - return () => { - ctx.api = original; + }); + const install = (api: unknown) => { + ctx.api = api as HookContext['api']; + return () => { + ctx.api = original; + }; }; + if (runAs !== 'user') return Promise.resolve(install(derived)); + // [#24497] A `'user'` pin carries the user's grants and peer list, seeded + // through the resolver the request path uses (`ScopedContext.withUserGrantsSeeded`). + // Cost: one `resolveUserAuthzGrants` per dispatch whose triggering context + // carries no `permissions` or no `org_user_ids` — the hand-built system + // contexts; a request-resolved context carries both and resolves nothing. + return seedHookUserGrants(derived).then(install, (err: unknown) => { + // Fail-safe, never fail-open — the flow run setup's posture: the hook keeps + // the unseeded pin, so its data ops run on the context's own grants (the + // security plugin's member baseline when it carries none, never elevation), + // and every row policy reading `current_user.org_user_ids` drops out. It + // sees less, never more. FUNCTIONAL, so `warn`: nothing claimed-persisted + // failed to land, and the symptom is the hook's own narrower reads. + logger.warn( + `[hook] runAs 'user': the triggering user's grants could not be resolved, so this hook's data ops run ` + + `on the triggering context's own grants (not elevated), and every row policy reading ` + + `current_user.org_user_ids drops out`, + { hook: hookName, object: ctx.object, event: ctx.event, error: (err as any)?.message }, + ); + return install(derived); + }); } +/** The restore of a hook whose `runAs` installed nothing (`'inherit'`). */ +const noRestore = (): void => {}; + /** * Swap `ctx.input` in place for a Proxy that exposes a flat record view * over the engine's `{ data, options, id? }` wrapper. Returns a function diff --git a/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts index d18cb7b2588..a52f217ecd5 100644 --- a/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts +++ b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts @@ -42,6 +42,9 @@ const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: ( const probeObject = { name: 'hook_probe', label: 'Hook probe', + // Platform-global, so the organisation-less firing below is a legal system + // write (ADR-0066) rather than a refusal about which organisation owns the row. + tenancy: { enabled: false }, fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, }; @@ -149,6 +152,7 @@ describe("a runAs:'user' hook sees the acting user and their organisation's memb const before = seen.length; await ql.insert('hook_probe', { name: 'p' }, { context: context as any }); expect(seen.length, 'the hook ran exactly once').toBe(before + 1); + console.log("PROBE", JSON.stringify(context), "=>", JSON.stringify(seen[seen.length - 1]), "ctx:", JSON.stringify(Object.fromEntries(Object.entries(seenContexts[seenContexts.length - 1] ?? {}).filter(([k]) => ["isSystem","userId","tenantId","positions","permissions","org_user_ids"].includes(k))))); return seen[seen.length - 1]; } @@ -163,16 +167,32 @@ describe("a runAs:'user' hook sees the acting user and their organisation's memb expect(await fire({ isSystem: true, userId: 'usr_me', tenantId: orgA })).toEqual(['usr_me', 'usr_peer']); }); - it('fired from a request-resolved context, the hook answers the same and keeps the envelope it was handed (control)', async () => { + it('fired from a context that already carries the request-resolved envelope, the hook answers the same and keeps that envelope verbatim (control)', async () => { await boot(); const req = await requestContext('usr_me', orgA); - expect(await fire(req)).toEqual(['usr_me', 'usr_peer']); + // The triggering write itself is elevated only because the member's + // shipped sets grant no create on the probe object; the hook's runAs:'user' + // pins it back to `isSystem: false`, which is exactly the request context. + expect(await fire({ ...req, isSystem: true })).toEqual(['usr_me', 'usr_peer']); const hookCtx = seenContexts[seenContexts.length - 1]; expect(hookCtx.org_user_ids).toEqual(req.org_user_ids); expect(hookCtx.positions).toEqual(req.positions); expect(hookCtx.permissions).toEqual(req.permissions); }); + it("fired from the approvals writes' exact envelope (empty positions and sets, no organisation), the hook carries a peer list: the user alone", async () => { + await boot(); + // `plugin-approvals`' SYSTEM_CTX plus the acting user: the status mirror + // deliberately carries no organisation, so the list is the organisation-less + // one. What moves is that the list EXISTS: a policy reading it resolves + // rather than dropping out. + expect(await fire({ isSystem: true, positions: [], permissions: [], userId: 'usr_me' })).toEqual(['usr_me']); + const hookCtx = seenContexts[seenContexts.length - 1]; + expect(hookCtx.org_user_ids).toEqual(['usr_me']); + expect(hookCtx.positions).toEqual([]); + expect(hookCtx.permissions).toEqual([]); + }); + it('fired with no organisation, the hook answers what an organisation-less request answers: the acting user alone', async () => { await boot(); const rows = await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me') }); From ae5e46854a4a348cd7c44e6ea7a8d15a51d6f741 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:42:38 +0000 Subject: [PATCH 3/6] test(service-automation): drop the measurement print from the hook probe Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- .../src/runas-user-hook-org-peers.integration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts index a52f217ecd5..3e0e73cb446 100644 --- a/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts +++ b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts @@ -152,7 +152,6 @@ describe("a runAs:'user' hook sees the acting user and their organisation's memb const before = seen.length; await ql.insert('hook_probe', { name: 'p' }, { context: context as any }); expect(seen.length, 'the hook ran exactly once').toBe(before + 1); - console.log("PROBE", JSON.stringify(context), "=>", JSON.stringify(seen[seen.length - 1]), "ctx:", JSON.stringify(Object.fromEntries(Object.entries(seenContexts[seenContexts.length - 1] ?? {}).filter(([k]) => ["isSystem","userId","tenantId","positions","permissions","org_user_ids"].includes(k))))); return seen[seen.length - 1]; } From 937daabd2edcd4b51bfd0da3d52cbec699ca414f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:59:02 +0000 Subject: [PATCH 4/6] chore(changeset): objectql patch for the runAs:'user' hook peer-list seed Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- .changeset/24497-hook-runas-org-peers.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/24497-hook-runas-org-peers.md diff --git a/.changeset/24497-hook-runas-org-peers.md b/.changeset/24497-hook-runas-org-peers.md new file mode 100644 index 00000000000..9ae6f9bd694 --- /dev/null +++ b/.changeset/24497-hook-runas-org-peers.md @@ -0,0 +1,18 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a `runAs: 'user'` hook now sees the acting user's organization members in `sys_user`, as the same user's own request does + +A hook declaring `runAs: 'user'` is pinned to the triggering user. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals writes are the shipped producers), the pinned context carried no `org_user_ids`, the peer list the request path resolves for every user. The shipped `sys_user_org_members` policy (`id in current_user.org_user_ids`) therefore dropped out, and the hook's `ctx.api` read of `sys_user` returned the acting user's own row only, while the same user's request returned their organization's members too. + +The hook wrapper now seeds the pinned context through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`): + +- The peer list is the members of the context's organization (`tenantId`) plus the acting user. A context with no organization gets the acting user alone, which is what an organization-less request gets. A user who belongs only to other organizations is never in it. +- A context with no `permissions` also takes the user's resolved positions and permission sets, so the hook acts with the user's real grants rather than the security plugin's member baseline. Positions and permission sets the context already carries stay exactly as they were, so a narrowed ceiling is not re-broadened; only the peer list is added. +- A context that already carries a peer list, such as a request-resolved one, resolves nothing and is handed through as before. +- If the resolver fails, the hook runs on the unseeded pin, as before, and a warning says so. Policies that read `org_user_ids` then drop out: the hook sees less, never more. + +`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 either list now pays one grants resolution per dispatch. + +Clause-②: no (a runtime producer fix: a `runAs: 'user'` hook reads what the same user's own request reads. Nothing `@objectstack/spec` declares moves; owes the seat's review of record before the queue) From e82725a185db27b45fcb83bc775d682855582db8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:06:02 +0000 Subject: [PATCH 5/6] fix(objectql): the peer-list seed reads no elevation flag (the system-context census) Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- packages/objectql/src/engine.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9a6147704c9..18a5715b278 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -21805,10 +21805,13 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * included; a context with no `tenantId` gets the user alone, as an * organisation-less request does. A resolver failure (an unreadable * permission store) is thrown to the caller, which keeps the unseeded api. + * + * Called on the api {@link withRunAs}`('user')` derived, which is never + * elevated, so it does not re-judge the elevation flag. */ async withUserGrantsSeeded(): Promise { const ctx = this.executionContext; - if (ctx.isSystem || !ctx.userId) return this; + if (!ctx.userId) return this; const needsGrants = !Array.isArray(ctx.permissions); const needsPeers = !Array.isArray(ctx.org_user_ids); if (!needsGrants && !needsPeers) return this; From f094a9d49cf8d19e2847731684c243854db03621 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 11:18:57 +0000 Subject: [PATCH 6/6] fix(objectql): key the hook seed on a module symbol, off ScopedContext's nameable public face Claude-Session: https://claude.ai/code/session_01PdsDLkA7E6UZywu8MVmeWa Co-authored-by: Claude --- packages/objectql/src/engine.ts | 10 +++++++--- packages/objectql/src/hook-run-as.test.ts | 7 +++++++ packages/objectql/src/hook-run-as.ts | 24 ++++++++++++++++++----- packages/objectql/src/hook-wrappers.ts | 2 +- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 18a5715b278..8b9d2ada426 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -147,7 +147,7 @@ import { // Layer 0 — see {@link buildDriverOptions}. computeTenantLayer0Verdict, // [#24497] The ONE grants resolver, the one the request path seeds a - // context's grants and peer list through — see `ScopedContext.withUserGrantsSeeded`. + // context's grants and peer list through — see `ScopedContext[SEED_USER_GRANTS]`. resolveUserAuthzGrants, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; @@ -313,7 +313,7 @@ import { divergingHookPayloadKeys, MultiUpdateHookKeyDivergenceError, } from './multi-update-hook-key-divergence.js'; -import { UnscopedHookApi, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js'; +import { UnscopedHookApi, SEED_USER_GRANTS, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js'; import type { HookWriteRecording } from './hook-write-provenance.js'; import { resolveMasterDetailRelation } from './master-detail.js'; // [#19911] The settlement's question "does judging the FK's own lock need the @@ -21808,8 +21808,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * * Called on the api {@link withRunAs}`('user')` derived, which is never * elevated, so it does not re-judge the elevation flag. + * + * Keyed on {@link SEED_USER_GRANTS}, a symbol the package entry does not + * export, so it is no nameable member of this class's published face (see + * the symbol's docblock in `hook-run-as.ts`). */ - async withUserGrantsSeeded(): Promise { + async [SEED_USER_GRANTS](): Promise { const ctx = this.executionContext; if (!ctx.userId) return this; const needsGrants = !Array.isArray(ctx.permissions); diff --git a/packages/objectql/src/hook-run-as.test.ts b/packages/objectql/src/hook-run-as.test.ts index 6d32f97345b..3fa5578d3e7 100644 --- a/packages/objectql/src/hook-run-as.test.ts +++ b/packages/objectql/src/hook-run-as.test.ts @@ -372,6 +372,13 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => expect(userless.seen).toEqual([]); }); + it('the seed is no nameable member of the published face: keyed on a symbol the package entry does not export', async () => { + const entry = await import('./index.js'); + expect('SEED_USER_GRANTS' in entry).toBe(false); + expect(Object.getOwnPropertyNames(ScopedContext.prototype)).not.toContain('withUserGrantsSeeded'); + expect(Object.getOwnPropertySymbols(ScopedContext.prototype)).toHaveLength(1); + }); + it('a resolver failure keeps the UNSEEDED pin — narrower, never elevated — and says so once at warn', async () => { const engine = grantsEngine({ failReads: true }); const warns: unknown[][] = []; diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index 9f5c4ae921b..c0f5b25f849 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -206,14 +206,28 @@ export function deriveHookApi(api: unknown, runAs: HookRunAs, ref: HookRunAsRef) return derivable.withRunAs(runAs, ref); } +/** + * The key the engine's `ScopedContext` carries the second half of a + * `runAs: 'user'` derivation under (#24497): seed the user's grants and peer + * list onto the api `withRunAs('user')` derived. + * + * A symbol, and ⛔ deliberately NOT re-exported from the package entry, so the + * method is no NAMEABLE member of `ScopedContext`'s published face: only the + * hook wrapper reaches it, through {@link seedHookUserGrants}. A public named + * method would widen the published surface, and it would also be a derivation + * the runtime's stored-metadata seams (`deriveThroughSeam`) do not know, so a + * seam-served host api calling it would get back an unserved context. + */ +export const SEED_USER_GRANTS: unique symbol = Symbol('objectstack.objectql.hookSeedUserGrants'); + /** * What the api a `runAs: 'user'` derivation produced must offer for the * user's grants and peer list to be seeded onto it (#24497). The engine's - * `ScopedContext` implements it (`engine.ts` `withUserGrantsSeeded`); the - * refusing {@link UnscopedHookApi} does not, and needs nothing seeded. + * `ScopedContext` implements it (`engine.ts`, under {@link SEED_USER_GRANTS}); + * the refusing {@link UnscopedHookApi} does not, and needs nothing seeded. */ interface UserGrantsSeedableApi { - withUserGrantsSeeded(): Promise; + [SEED_USER_GRANTS](): Promise; } /** @@ -225,6 +239,6 @@ interface UserGrantsSeedableApi { */ export async function seedHookUserGrants(api: unknown): Promise { const seedable = api as Partial | undefined; - if (!seedable || typeof seedable.withUserGrantsSeeded !== 'function') return api; - return seedable.withUserGrantsSeeded(); + if (!seedable || typeof seedable[SEED_USER_GRANTS] !== 'function') return api; + return seedable[SEED_USER_GRANTS](); } diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index b37ee8c33bc..6a2d4695077 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -558,7 +558,7 @@ function installRunAsApi( }; if (runAs !== 'user') return Promise.resolve(install(derived)); // [#24497] A `'user'` pin carries the user's grants and peer list, seeded - // through the resolver the request path uses (`ScopedContext.withUserGrantsSeeded`). + // through the resolver the request path uses (`ScopedContext[SEED_USER_GRANTS]`, `hook-run-as.ts`). // Cost: one `resolveUserAuthzGrants` per dispatch whose triggering context // carries no `permissions` or no `org_user_ids` — the hand-built system // contexts; a request-resolved context carries both and resolves nothing.