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) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 53961e58c5a..8b9d2ada426 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[SEED_USER_GRANTS]`. + resolveUserAuthzGrants, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; @@ -310,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 @@ -21776,6 +21779,61 @@ 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. + * + * 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 [SEED_USER_GRANTS](): Promise { + const ctx = this.executionContext; + if (!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..3fa5578d3e7 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,114 @@ 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('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[][] = []; + 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..c0f5b25f849 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -205,3 +205,40 @@ 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`, under {@link SEED_USER_GRANTS}); + * the refusing {@link UnscopedHookApi} does not, and needs nothing seeded. + */ +interface UserGrantsSeedableApi { + [SEED_USER_GRANTS](): 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[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 fb6056cc756..6a2d4695077 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[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. + 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 new file mode 100644 index 00000000000..3e0e73cb446 --- /dev/null +++ b/packages/services/service-automation/src/runas-user-hook-org-peers.integration.test.ts @@ -0,0 +1,201 @@ +// 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', + // 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 } }, +}; + +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 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); + // 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') }); + expect(ids(rows)).toEqual(['usr_me']); + expect(await fire({ isSystem: true, userId: 'usr_me' })).toEqual(['usr_me']); + }); +});