From 6ca644728783aa12ba28e0e43e51c2fa29910913 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 07:04:42 +0000 Subject: [PATCH 1/3] test(service-automation): probe a runAs:'user' flow's sys_user reach against the request path The flow's data context carries no org_user_ids, so sys_user_org_members drops out and the run sees only the acting user's row. Probe first; the fix follows. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .../runas-user-org-peers.integration.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 packages/services/service-automation/src/runas-user-org-peers.integration.test.ts 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']); + }); +}); From 373edbc79c97944ffdab057d18f2a558fcf7a719 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 07:17:43 +0000 Subject: [PATCH 2/3] fix(service-automation): seed a runAs:'user' run's org_user_ids at run setup A runAs:'user' flow's data context carried no org_user_ids, so the shipped 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. Run setup now takes the peer list from the grants resolver the plugin bridges to resolveUserAuthzGrants (the request path's resolver), also when the trigger already carried positions/permissions (only the peer list is taken then), and resolveRunDataContext passes it to every data node. A context that already carries one keeps it. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .changeset/24448-run-context-org-user-ids.md | 19 +++++ .../src/builtin/crud-runas.test.ts | 84 +++++++++++++++++-- .../services/service-automation/src/engine.ts | 57 +++++++++++-- .../services/service-automation/src/plugin.ts | 5 ++ ...runas-grant-resolution.integration.test.ts | 3 + .../src/runtime-identity.ts | 54 +++++++++++- 6 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 .changeset/24448-run-context-org-user-ids.md 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..52b4c0b6ae8 --- /dev/null +++ b/.changeset/24448-run-context-org-user-ids.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-automation': patch +--- + +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-②: no 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/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; From 7e6f99f7a409fdb76cea099a4c232b94737e1095 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:17:44 +0000 Subject: [PATCH 3/3] chore(changeset): service-automation takes minor for the additive RunIdentityContext member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, an additive widening of a published package's public surface, so the level is minor and Clause-② is yes (widening). Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude --- .changeset/24448-run-context-org-user-ids.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24448-run-context-org-user-ids.md b/.changeset/24448-run-context-org-user-ids.md index 52b4c0b6ae8..0f177647684 100644 --- a/.changeset/24448-run-context-org-user-ids.md +++ b/.changeset/24448-run-context-org-user-ids.md @@ -1,5 +1,5 @@ --- -'@objectstack/service-automation': patch +'@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 @@ -16,4 +16,4 @@ Run setup now seeds the peer list through the resolver the request path uses (`r A `runAs: 'system'` flow is unchanged. The exported `RunIdentityContext` type gains an optional `org_user_ids` member. -Clause-②: no +Clause-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves)