diff --git a/.changeset/24448-run-context-org-user-ids.md b/.changeset/24448-run-context-org-user-ids.md new file mode 100644 index 00000000000..0f177647684 --- /dev/null +++ b/.changeset/24448-run-context-org-user-ids.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation): a `runAs: 'user'` flow now sees the acting user's organization members in `sys_user`, as the same user's own request does + +A `runAs: 'user'` flow's data nodes ran without `current_user.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 a `get_record` on `sys_user` returned the acting user's own row only, while the same user's REST read returned their organization's members too. + +Run setup now seeds the peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`), and every data node passes it on: + +- It is the members of the run's organization (`tenantId`) plus the acting user. A run 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, even one who shares another organization with the acting user. +- It is seeded for every trigger shape: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). For the second shape only the peer list is taken from the resolver; the forwarded positions and permission sets stay exactly as they were, so an agent's narrowed ceiling is not re-broadened. +- A `subflow` or `map` child and a resumed leg keep the list their run was seeded with. +- If the resolver fails, the run proceeds without the list, as before, and the `[runAs]` warning now says which half degraded. Policies that read `org_user_ids` then drop out: the run sees less, never more. +- A run suspended before this release resumes without the list and keeps today's answer (its own `sys_user` row) until it ends. + +A `runAs: 'system'` flow is unchanged. The exported `RunIdentityContext` type gains an optional `org_user_ids` member. + +Clause-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves) diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 78d95668f2c..4e01387c43e 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -30,6 +30,7 @@ import { runIsUnscopedUserMode, flowTouchesData, UnscopedRunDataAccessError, + type SeededRunContext, } from '../runtime-identity.js'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -439,26 +440,95 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356 } }); - it('does NOT re-resolve when the trigger already carried permissions (agent/REST envelope preserved)', async () => { + it('does NOT re-broaden a trigger that already carried permissions — only the peer list is taken (agent/REST envelope preserved)', async () => { const engine = new AutomationEngine(makeLogger()); const { data, calls } = fakeData(); registerCrudNodes(engine, ctxWith(data)); engine.registerFlow('usr', allOpsFlow('usr', 'user')); - let called = 0; - engine.setUserGrantsResolver(() => { - called++; - return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'] }; + const seen: Array<{ userId: string; tenantId?: string }> = []; + engine.setUserGrantsResolver((userId, tenantId) => { + seen.push({ userId, tenantId }); + return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['u1', 'u2'] }; }); // An already-resolved envelope — e.g. an ADR-0090 agent ceiling acting // on-behalf-of a user (always non-empty). Must be honored verbatim, NOT - // re-broadened to the human's full grants. + // re-broadened to the human's full grants. No door forwards the peer + // list, so the resolver is asked for that, in the run's organization. await engine.execute('usr', { userId: 'u1', positions: ['agent'], permissions: ['mcp_agent_read'], tenantId: 't1' }); - expect(called).toBe(0); + expect(seen).toEqual([{ userId: 'u1', tenantId: 't1' }]); + expect(calls.length).toBeGreaterThan(0); for (const c of calls) { expect(c.ctx.permissions).toEqual(['mcp_agent_read']); expect(c.ctx.positions).toEqual(['agent']); + expect(c.ctx.org_user_ids, `${c.op} lost the seeded peer list`).toEqual(['u1', 'u2']); + } + }); + + it('resolves nothing when the trigger already carried permissions AND a peer list (a subflow child, a resumed leg)', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + + let called = 0; + engine.setUserGrantsResolver(() => { + called++; + return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['SHOULD_NOT_APPLY'] }; + }); + + await engine.execute('usr', { + userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1', org_user_ids: ['u1', 'u3'], + } as SeededRunContext); + expect(called).toBe(0); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) { + expect(c.ctx.permissions).toEqual(['member_default']); + expect(c.ctx.org_user_ids).toEqual(['u1', 'u3']); + } + }); + + it('seeds the peer list beside the resolved grants and threads it to EVERY data op', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'], org_user_ids: ['u1', 'u2'] })); + + const res = await engine.execute('usr', { userId: 'u1', tenantId: 't1' }); + expect(res.success).toBe(true); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect(c.ctx.org_user_ids, `${c.op} lost the seeded peer list`).toEqual(['u1', 'u2']); + }); + + it('a resolver that answers no peer list seeds none — the data context carries no org_user_ids, never a made-up one', async () => { + const engine = new AutomationEngine(makeLogger()); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'] })); + + await engine.execute('usr', { userId: 'u1', tenantId: 't1' }); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect('org_user_ids' in c.ctx, `${c.op} carried an invented peer list`).toBe(false); + }); + + it('a resolver error with permissions already carried keeps the envelope, seeds no peers, and says which half degraded', async () => { + const { logger, warns } = recordingLogger(); + const engine = new AutomationEngine(logger); + const { data, calls } = fakeData(); + registerCrudNodes(engine, ctxWith(data)); + engine.registerFlow('usr', allOpsFlow('usr', 'user')); + engine.setUserGrantsResolver(() => { throw new Error('db down'); }); + + const res = await engine.execute('usr', { userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1' }); + expect(res.success).toBe(true); + expect(runAsWarns(warns).some((w) => w.includes("keep the trigger's positions and permission sets"))).toBe(true); + for (const c of calls) { + expect(c.ctx.isSystem).toBe(false); + expect(c.ctx.permissions).toEqual(['member_default']); + expect('org_user_ids' in c.ctx).toBe(false); } }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4f5ce54192b..7d06c7464a8 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -212,7 +212,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record> = { '`required` field meant to stay hidden then blocks the screen from ever being submitted.', }, }; -import { runIsUnscopedUserMode, flowTouchesData } from './runtime-identity.js'; +import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, type SeededRunContext } from './runtime-identity.js'; import { isGuardRefusal, refuseNode } from './guard-refusal.js'; import { readPartialSteps } from './partial-steps.js'; import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js'; @@ -723,12 +723,22 @@ export type FlowObjectSchemaResolver = ( * The authorization envelope a `runAs:'user'` run needs to enforce its data * ops as the triggering user (#3356): the user's resolved position names and * permission-set names (the two lists the data security middleware keys on), - * plus their tenant. Built by {@link FlowUserGrantsResolver}. + * plus their tenant and their fellow-organization peers. Built by + * {@link FlowUserGrantsResolver}. */ export interface FlowUserGrants { positions: string[]; permissions: string[]; tenantId?: string; + /** + * The user's peers in the organization the resolver was asked about, the + * user included: the value RLS reads as `current_user.org_user_ids` + * (`resolveUserAuthzGrants`' own field, under its own name). Run setup seeds + * it on the run context ({@link SeededRunContext}). A resolver that omits it + * leaves the run without one, so every policy that reads it drops out: the + * run sees less, never more. + */ + org_user_ids?: string[]; } /** @@ -6412,18 +6422,40 @@ export class AutomationEngine implements IAutomationService { // left untouched: that includes an ADR-0090 agent principal acting // on-behalf-of a user (its scope-derived ceiling is always non-empty), so // this never re-broadens a deliberately narrowed identity. + // + // The run's peer list (`org_user_ids`) is seeded here too, through the + // same resolver, whenever the context does not carry one yet — so also + // for a context whose positions/permissions the door already resolved. + // No trigger surface forwards it (the REST, action and record-change + // doors all hand over positions/permissions/tenant at most), so without + // it every `runAs:'user'` run's `sys_user_org_members` policy compiled + // `unresolved-variable` and dropped out: the run saw only the acting + // user's own `sys_user` row while the same user's request saw their + // organization's members too. Taking ONLY the peer list from a + // re-resolution leaves the carried positions/permissions verbatim, so an + // agent ceiling stays narrowed; the list itself is the delegating + // human's peers in the run's organization, which is what the request + // path's agent envelope carries as well. A context that already carries + // one — a `subflow` / `map` child, a resumed leg — keeps it. + const needsGrants = !Array.isArray(runContext.permissions); + const needsPeers = runOrgUserIds(runContext) === undefined; if ( runContext.runAs !== 'system' && runContext.userId && - !Array.isArray(runContext.permissions) && + (needsGrants || needsPeers) && this.userGrantsResolver ) { try { const grants = await this.userGrantsResolver(runContext.userId, runContext.tenantId); if (grants) { - runContext.positions = Array.isArray(grants.positions) ? grants.positions : []; - runContext.permissions = Array.isArray(grants.permissions) ? grants.permissions : []; - if (grants.tenantId && !runContext.tenantId) runContext.tenantId = grants.tenantId; + if (needsGrants) { + runContext.positions = Array.isArray(grants.positions) ? grants.positions : []; + runContext.permissions = Array.isArray(grants.permissions) ? grants.permissions : []; + if (grants.tenantId && !runContext.tenantId) runContext.tenantId = grants.tenantId; + } + if (needsPeers && Array.isArray(grants.org_user_ids)) { + (runContext as SeededRunContext).org_user_ids = grants.org_user_ids; + } } } catch (err) { // Fail-safe, never fail-open: on a resolution error the run keeps @@ -6438,10 +6470,19 @@ export class AutomationEngine implements IAutomationService { // degradation is fail-safe as argued above, its symptom is the // run's own data-op refusals/strips, and nothing // claimed-persisted failed to land. + // + // Unresolved peers degrade the same way, narrower and never + // wider: a policy that reads `current_user.org_user_ids` drops + // out, so the run sees its own `sys_user` row and not its + // organization's members. The sentence names which half applies. + const consequence = needsGrants + ? `its data ops fall back to baseline member permissions (not elevated), and every ` + + `row policy reading current_user.org_user_ids drops out` + : `its data ops keep the trigger's positions and permission sets, but every row ` + + `policy reading current_user.org_user_ids drops out (narrower, not elevated)`; this.logger.warn( `[runAs] flow '${flow.name}' could not resolve grants for triggering user ` + - `'${runContext.userId}' — its data ops fall back to baseline member permissions ` + - `(not elevated). The resolver's failure is in this record's meta.`, + `'${runContext.userId}' — ${consequence}. The resolver's failure is in this record's meta.`, describeThrownForLog(err), ); } diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 3db495e2739..7b428b19954 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1293,6 +1293,11 @@ export class AutomationServicePlugin implements Plugin { return { positions: grants.positions, permissions: grants.permissions, + // The peer list the request path seeds through this + // same resolver: the run's organization's members and + // the user, so `sys_user_org_members` resolves for the + // run as it does for the user's own request. + org_user_ids: grants.org_user_ids, ...(tenantId ? { tenantId } : {}), }; }); diff --git a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts index cd9a48fc11d..8129fbc400f 100644 --- a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts +++ b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts @@ -111,6 +111,9 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356 update!.ctx.permissions, "the triggering user's permission set was not propagated — runAs:'user' ran with the bare member fallback (#3356)", ).toContain('ehr_all'); + // The peer list rides the same bridge. No organization here, so it is the + // user alone — what an organization-less request resolves to as well. + expect(update!.ctx.org_user_ids, 'the bridge dropped the resolver\'s peer list').toEqual(['u1']); await kernel.shutdown(); }); diff --git a/packages/services/service-automation/src/runas-user-org-peers.integration.test.ts b/packages/services/service-automation/src/runas-user-org-peers.integration.test.ts new file mode 100644 index 00000000000..276a8143f27 --- /dev/null +++ b/packages/services/service-automation/src/runas-user-org-peers.integration.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `runAs:'user'` flow reads `sys_user` exactly as the triggering user's own + * request does: the acting user and the members of the run's organisation, + * and nothing beyond. + * + * The defect this pins: the run's data context carried no `org_user_ids`. + * `sys_user`'s shipped member scope is two policies, `sys_user_self` + * (`id == current_user.id`) and `sys_user_org_members` + * (`id in current_user.org_user_ids`); the second compiles + * `unresolved-variable` when the peer list is absent and drops out, so the + * run saw only the acting user's own row while the same user's request saw + * their colleagues too. On the request path the peer list is seeded by the + * core resolver (`resolveUserAuthzGrants`, through `resolveAuthzContext`); + * the run now seeds it through the same resolver at run setup. + * + * The REAL stack runs here — ObjectKernel, ObjectQLPlugin, driver-sql on + * better-sqlite3 `:memory:`, the identity objects, the real SecurityPlugin + * with its shipped default permission sets, and the real AutomationEngine and + * CRUD nodes — because the defect lived in the hand-off between the run's + * identity and the RLS compiler, which no single-layer test sees. + * + * 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. Acting in A, + * "nothing beyond" means neither `usr_other` (a colleague of `usr_me` in a + * DIFFERENT organisation) nor `usr_loner`. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; + +const SYS = { isSystem: true } as const; + +/** start → get_record(sys_user, many) → end, under runAs:'user'; the rows land in the `users` output. */ +const directoryFlow = (name: string) => ({ + name, + label: name, + type: 'autolaunched', + runAs: 'user', + variables: [{ name: 'users', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'read', + type: 'get_record', + label: 'Read directory', + config: { objectName: 'sys_user', fields: ['id', 'name'], limit: 50, outputVariable: 'users' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'end' }, + ], +}); + +describe("a runAs:'user' flow sees the acting user and their organisation's members, and nothing beyond", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + const orgB = 'org_b'; + + 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(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin()); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + + 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 }); + } + } + + /** + * 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) => (Array.isArray(rows) ? rows.map((r: any) => String(r.id)).sort() : rows); + + async function runDirectory(trigger: Record) { + automation.registerFlow('directory', directoryFlow('directory') as any); + const res = await automation.execute('directory', trigger as any); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + return ids((res.output as { users?: unknown } | undefined)?.users); + } + + 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('a run triggered with the record-change shape ({ userId, tenantId }) answers what the request answers', async () => { + await boot(); + expect(await runDirectory({ userId: 'usr_me', tenantId: orgA })).toEqual(['usr_me', 'usr_peer']); + }); + + it("a run triggered with the doors' pre-resolved shape (positions and permissions already present) answers the same", async () => { + await boot(); + const req = await requestContext('usr_me', orgA); + expect(req.permissions.length, 'the doors forward permissions only when non-empty').toBeGreaterThan(0); + expect( + await runDirectory({ userId: 'usr_me', tenantId: orgA, positions: req.positions, permissions: req.permissions }), + ).toEqual(['usr_me', 'usr_peer']); + }); + + it('a run with no organisation 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 runDirectory({ userId: 'usr_me' })).toEqual(['usr_me']); + }); +}); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 613932cfc20..9032572de01 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -42,6 +42,16 @@ export interface RunIdentityContext { positions: string[]; /** Acting user's explicit permission-set names. */ permissions: string[]; + /** + * The acting user's fellow-organization peers, the user included: the value + * RLS reads as `current_user.org_user_ids` (`sys_user_org_members` and every + * other `id in current_user.org_user_ids` policy). Present on a + * `runAs:'user'` run whose setup seeded it ({@link runOrgUserIds}); a policy + * that reads it compiles `unresolved-variable` and drops out without it, so + * the run would see only the rows its other policies admit. Never set on a + * `runAs:'system'` run, whose `isSystem` short-circuit precedes every policy. + */ + org_user_ids?: string[]; /** Acting user's tenant/org id. */ tenantId?: string; /** @@ -108,6 +118,36 @@ export class UnscopedRunDataAccessError extends Error { */ export interface RunDataContext extends RunIdentityContext {} +/** + * A flow run's {@link AutomationContext} as run setup leaves it: the context + * the trigger supplied, plus the identity facts the engine seeds for a + * `runAs:'user'` run. `AutomationEngine.resolveRunContext` is the only writer. + * + * `org_user_ids` is the request path's peer list, from the same resolver: the + * automation plugin bridges the engine's grants resolver to + * `@objectstack/core`'s `resolveUserAuthzGrants`, which `resolveAuthzContext` + * also seeds a request's `org_user_ids` through. So it is the members of the + * run's organization (`tenantId`) plus the acting user, or the acting user + * alone when the run has no organization, exactly as a request in that + * organization answers. It rides the run context from setup on: into every + * data node through {@link resolveRunDataContext}, into a `subflow` / `map` + * child with the rest of the parent's context, and through a suspension with + * the persisted context, the way `positions` / `permissions` do. + */ +export interface SeededRunContext extends AutomationContext { + org_user_ids?: string[]; +} + +/** + * The peer list run setup seeded on `context` ({@link SeededRunContext}), or + * `undefined` when there is none. `undefined` is never widened into a list: + * the policies that read it drop out instead, which narrows (fail closed). + */ +export function runOrgUserIds(context: AutomationContext | undefined): string[] | undefined { + const peers = (context as SeededRunContext | undefined)?.org_user_ids; + return Array.isArray(peers) ? peers : undefined; +} + /** * Translate a flow run's {@link AutomationContext} into the ObjectQL `context` * its CRUD nodes must pass, honoring `runAs` (ADR-0049 / #1888): @@ -132,10 +172,14 @@ export interface RunDataContext extends RunIdentityContext {} * `flowRunId` are its provenance, and ADR-0118 D1 forbids inventing a * sentinel or pseudo-user in its place. * - `runAs:'user'` (default) → the triggering user's identity - * (`{ userId, positions, permissions, tenantId? }`), so the security middleware - * enforces that user's row-level security. The run can never exceed the - * triggering user's grants. Empty `positions` falls back to the platform's - * baseline permission set, exactly like a fresh member's own REST request. + * (`{ userId, positions, permissions, org_user_ids?, tenantId? }`), so the + * security middleware enforces that user's row-level security. The run can + * never exceed the triggering user's grants. Empty `positions` falls back to + * the platform's baseline permission set, exactly like a fresh member's own + * REST request. `org_user_ids` is the peer list run setup seeded + * ({@link SeededRunContext}): with it, `sys_user` answers the acting user and + * their organization's members, as the same user's request does; without + * it, every policy that reads it drops out. * - neither (an effective `runAs:'user'` run with no trigger user) → **throws** * {@link UnscopedRunDataAccessError} (#3760). This used to return a * provenance-only envelope and let the run proceed UNSCOPED — the #1888 @@ -225,6 +269,8 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R positions: Array.isArray(context.positions) ? context.positions : [], permissions: Array.isArray(context.permissions) ? context.permissions : [], }; + const orgUserIds = runOrgUserIds(context); + if (orgUserIds) out.org_user_ids = orgUserIds; if (context.tenantId) out.tenantId = context.tenantId; if (flowRunId) out.flowRunId = flowRunId; return out;