Skip to content

Commit 373edbc

Browse files
committed
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 <noreply@anthropic.com>
1 parent 6ca6447 commit 373edbc

6 files changed

Lines changed: 203 additions & 19 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
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
6+
7+
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.
8+
9+
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:
10+
11+
- 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.
12+
- 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.
13+
- A `subflow` or `map` child and a resumed leg keep the list their run was seeded with.
14+
- 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.
15+
- A run suspended before this release resumes without the list and keeps today's answer (its own `sys_user` row) until it ends.
16+
17+
A `runAs: 'system'` flow is unchanged. The exported `RunIdentityContext` type gains an optional `org_user_ids` member.
18+
19+
Clause-②: no

‎packages/services/service-automation/src/builtin/crud-runas.test.ts‎

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
runIsUnscopedUserMode,
3131
flowTouchesData,
3232
UnscopedRunDataAccessError,
33+
type SeededRunContext,
3334
} from '../runtime-identity.js';
3435
import type { AutomationContext } from '@objectstack/spec/contracts';
3536
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
@@ -439,26 +440,95 @@ describe("runAs:'user' resolves the triggering user's grants at run setup (#3356
439440
}
440441
});
441442

442-
it('does NOT re-resolve when the trigger already carried permissions (agent/REST envelope preserved)', async () => {
443+
it('does NOT re-broaden a trigger that already carried permissions — only the peer list is taken (agent/REST envelope preserved)', async () => {
443444
const engine = new AutomationEngine(makeLogger());
444445
const { data, calls } = fakeData();
445446
registerCrudNodes(engine, ctxWith(data));
446447
engine.registerFlow('usr', allOpsFlow('usr', 'user'));
447448

448-
let called = 0;
449-
engine.setUserGrantsResolver(() => {
450-
called++;
451-
return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'] };
449+
const seen: Array<{ userId: string; tenantId?: string }> = [];
450+
engine.setUserGrantsResolver((userId, tenantId) => {
451+
seen.push({ userId, tenantId });
452+
return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['u1', 'u2'] };
452453
});
453454

454455
// An already-resolved envelope — e.g. an ADR-0090 agent ceiling acting
455456
// on-behalf-of a user (always non-empty). Must be honored verbatim, NOT
456-
// re-broadened to the human's full grants.
457+
// re-broadened to the human's full grants. No door forwards the peer
458+
// list, so the resolver is asked for that, in the run's organization.
457459
await engine.execute('usr', { userId: 'u1', positions: ['agent'], permissions: ['mcp_agent_read'], tenantId: 't1' });
458-
expect(called).toBe(0);
460+
expect(seen).toEqual([{ userId: 'u1', tenantId: 't1' }]);
461+
expect(calls.length).toBeGreaterThan(0);
459462
for (const c of calls) {
460463
expect(c.ctx.permissions).toEqual(['mcp_agent_read']);
461464
expect(c.ctx.positions).toEqual(['agent']);
465+
expect(c.ctx.org_user_ids, `${c.op} lost the seeded peer list`).toEqual(['u1', 'u2']);
466+
}
467+
});
468+
469+
it('resolves nothing when the trigger already carried permissions AND a peer list (a subflow child, a resumed leg)', async () => {
470+
const engine = new AutomationEngine(makeLogger());
471+
const { data, calls } = fakeData();
472+
registerCrudNodes(engine, ctxWith(data));
473+
engine.registerFlow('usr', allOpsFlow('usr', 'user'));
474+
475+
let called = 0;
476+
engine.setUserGrantsResolver(() => {
477+
called++;
478+
return { positions: ['SHOULD_NOT_APPLY'], permissions: ['SHOULD_NOT_APPLY'], org_user_ids: ['SHOULD_NOT_APPLY'] };
479+
});
480+
481+
await engine.execute('usr', {
482+
userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1', org_user_ids: ['u1', 'u3'],
483+
} as SeededRunContext);
484+
expect(called).toBe(0);
485+
expect(calls.length).toBeGreaterThan(0);
486+
for (const c of calls) {
487+
expect(c.ctx.permissions).toEqual(['member_default']);
488+
expect(c.ctx.org_user_ids).toEqual(['u1', 'u3']);
489+
}
490+
});
491+
492+
it('seeds the peer list beside the resolved grants and threads it to EVERY data op', async () => {
493+
const engine = new AutomationEngine(makeLogger());
494+
const { data, calls } = fakeData();
495+
registerCrudNodes(engine, ctxWith(data));
496+
engine.registerFlow('usr', allOpsFlow('usr', 'user'));
497+
engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'], org_user_ids: ['u1', 'u2'] }));
498+
499+
const res = await engine.execute('usr', { userId: 'u1', tenantId: 't1' });
500+
expect(res.success).toBe(true);
501+
expect(calls.length).toBeGreaterThan(0);
502+
for (const c of calls) expect(c.ctx.org_user_ids, `${c.op} lost the seeded peer list`).toEqual(['u1', 'u2']);
503+
});
504+
505+
it('a resolver that answers no peer list seeds none — the data context carries no org_user_ids, never a made-up one', async () => {
506+
const engine = new AutomationEngine(makeLogger());
507+
const { data, calls } = fakeData();
508+
registerCrudNodes(engine, ctxWith(data));
509+
engine.registerFlow('usr', allOpsFlow('usr', 'user'));
510+
engine.setUserGrantsResolver(() => ({ positions: ['org_member'], permissions: ['member_default'] }));
511+
512+
await engine.execute('usr', { userId: 'u1', tenantId: 't1' });
513+
expect(calls.length).toBeGreaterThan(0);
514+
for (const c of calls) expect('org_user_ids' in c.ctx, `${c.op} carried an invented peer list`).toBe(false);
515+
});
516+
517+
it('a resolver error with permissions already carried keeps the envelope, seeds no peers, and says which half degraded', async () => {
518+
const { logger, warns } = recordingLogger();
519+
const engine = new AutomationEngine(logger);
520+
const { data, calls } = fakeData();
521+
registerCrudNodes(engine, ctxWith(data));
522+
engine.registerFlow('usr', allOpsFlow('usr', 'user'));
523+
engine.setUserGrantsResolver(() => { throw new Error('db down'); });
524+
525+
const res = await engine.execute('usr', { userId: 'u1', positions: ['member'], permissions: ['member_default'], tenantId: 't1' });
526+
expect(res.success).toBe(true);
527+
expect(runAsWarns(warns).some((w) => w.includes("keep the trigger's positions and permission sets"))).toBe(true);
528+
for (const c of calls) {
529+
expect(c.ctx.isSystem).toBe(false);
530+
expect(c.ctx.permissions).toEqual(['member_default']);
531+
expect('org_user_ids' in c.ctx).toBe(false);
462532
}
463533
});
464534

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record<string, Record<string, string>> = {
212212
'`required` field meant to stay hidden then blocks the screen from ever being submitted.',
213213
},
214214
};
215-
import { runIsUnscopedUserMode, flowTouchesData } from './runtime-identity.js';
215+
import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, type SeededRunContext } from './runtime-identity.js';
216216
import { isGuardRefusal, refuseNode } from './guard-refusal.js';
217217
import { readPartialSteps } from './partial-steps.js';
218218
import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js';
@@ -723,12 +723,22 @@ export type FlowObjectSchemaResolver = (
723723
* The authorization envelope a `runAs:'user'` run needs to enforce its data
724724
* ops as the triggering user (#3356): the user's resolved position names and
725725
* permission-set names (the two lists the data security middleware keys on),
726-
* plus their tenant. Built by {@link FlowUserGrantsResolver}.
726+
* plus their tenant and their fellow-organization peers. Built by
727+
* {@link FlowUserGrantsResolver}.
727728
*/
728729
export interface FlowUserGrants {
729730
positions: string[];
730731
permissions: string[];
731732
tenantId?: string;
733+
/**
734+
* The user's peers in the organization the resolver was asked about, the
735+
* user included: the value RLS reads as `current_user.org_user_ids`
736+
* (`resolveUserAuthzGrants`' own field, under its own name). Run setup seeds
737+
* it on the run context ({@link SeededRunContext}). A resolver that omits it
738+
* leaves the run without one, so every policy that reads it drops out: the
739+
* run sees less, never more.
740+
*/
741+
org_user_ids?: string[];
732742
}
733743

734744
/**
@@ -6412,18 +6422,40 @@ export class AutomationEngine implements IAutomationService {
64126422
// left untouched: that includes an ADR-0090 agent principal acting
64136423
// on-behalf-of a user (its scope-derived ceiling is always non-empty), so
64146424
// this never re-broadens a deliberately narrowed identity.
6425+
//
6426+
// The run's peer list (`org_user_ids`) is seeded here too, through the
6427+
// same resolver, whenever the context does not carry one yet — so also
6428+
// for a context whose positions/permissions the door already resolved.
6429+
// No trigger surface forwards it (the REST, action and record-change
6430+
// doors all hand over positions/permissions/tenant at most), so without
6431+
// it every `runAs:'user'` run's `sys_user_org_members` policy compiled
6432+
// `unresolved-variable` and dropped out: the run saw only the acting
6433+
// user's own `sys_user` row while the same user's request saw their
6434+
// organization's members too. Taking ONLY the peer list from a
6435+
// re-resolution leaves the carried positions/permissions verbatim, so an
6436+
// agent ceiling stays narrowed; the list itself is the delegating
6437+
// human's peers in the run's organization, which is what the request
6438+
// path's agent envelope carries as well. A context that already carries
6439+
// one — a `subflow` / `map` child, a resumed leg — keeps it.
6440+
const needsGrants = !Array.isArray(runContext.permissions);
6441+
const needsPeers = runOrgUserIds(runContext) === undefined;
64156442
if (
64166443
runContext.runAs !== 'system' &&
64176444
runContext.userId &&
6418-
!Array.isArray(runContext.permissions) &&
6445+
(needsGrants || needsPeers) &&
64196446
this.userGrantsResolver
64206447
) {
64216448
try {
64226449
const grants = await this.userGrantsResolver(runContext.userId, runContext.tenantId);
64236450
if (grants) {
6424-
runContext.positions = Array.isArray(grants.positions) ? grants.positions : [];
6425-
runContext.permissions = Array.isArray(grants.permissions) ? grants.permissions : [];
6426-
if (grants.tenantId && !runContext.tenantId) runContext.tenantId = grants.tenantId;
6451+
if (needsGrants) {
6452+
runContext.positions = Array.isArray(grants.positions) ? grants.positions : [];
6453+
runContext.permissions = Array.isArray(grants.permissions) ? grants.permissions : [];
6454+
if (grants.tenantId && !runContext.tenantId) runContext.tenantId = grants.tenantId;
6455+
}
6456+
if (needsPeers && Array.isArray(grants.org_user_ids)) {
6457+
(runContext as SeededRunContext).org_user_ids = grants.org_user_ids;
6458+
}
64276459
}
64286460
} catch (err) {
64296461
// Fail-safe, never fail-open: on a resolution error the run keeps
@@ -6438,10 +6470,19 @@ export class AutomationEngine implements IAutomationService {
64386470
// degradation is fail-safe as argued above, its symptom is the
64396471
// run's own data-op refusals/strips, and nothing
64406472
// claimed-persisted failed to land.
6473+
//
6474+
// Unresolved peers degrade the same way, narrower and never
6475+
// wider: a policy that reads `current_user.org_user_ids` drops
6476+
// out, so the run sees its own `sys_user` row and not its
6477+
// organization's members. The sentence names which half applies.
6478+
const consequence = needsGrants
6479+
? `its data ops fall back to baseline member permissions (not elevated), and every ` +
6480+
`row policy reading current_user.org_user_ids drops out`
6481+
: `its data ops keep the trigger's positions and permission sets, but every row ` +
6482+
`policy reading current_user.org_user_ids drops out (narrower, not elevated)`;
64416483
this.logger.warn(
64426484
`[runAs] flow '${flow.name}' could not resolve grants for triggering user ` +
6443-
`'${runContext.userId}' — its data ops fall back to baseline member permissions ` +
6444-
`(not elevated). The resolver's failure is in this record's meta.`,
6485+
`'${runContext.userId}' — ${consequence}. The resolver's failure is in this record's meta.`,
64456486
describeThrownForLog(err),
64466487
);
64476488
}

‎packages/services/service-automation/src/plugin.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,11 @@ export class AutomationServicePlugin implements Plugin {
12931293
return {
12941294
positions: grants.positions,
12951295
permissions: grants.permissions,
1296+
// The peer list the request path seeds through this
1297+
// same resolver: the run's organization's members and
1298+
// the user, so `sys_user_org_members` resolves for the
1299+
// run as it does for the user's own request.
1300+
org_user_ids: grants.org_user_ids,
12961301
...(tenantId ? { tenantId } : {}),
12971302
};
12981303
});

‎packages/services/service-automation/src/runas-grant-resolution.integration.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356
111111
update!.ctx.permissions,
112112
"the triggering user's permission set was not propagated — runAs:'user' ran with the bare member fallback (#3356)",
113113
).toContain('ehr_all');
114+
// The peer list rides the same bridge. No organization here, so it is the
115+
// user alone — what an organization-less request resolves to as well.
116+
expect(update!.ctx.org_user_ids, 'the bridge dropped the resolver\'s peer list').toEqual(['u1']);
114117

115118
await kernel.shutdown();
116119
});

‎packages/services/service-automation/src/runtime-identity.ts‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ export interface RunIdentityContext {
4242
positions: string[];
4343
/** Acting user's explicit permission-set names. */
4444
permissions: string[];
45+
/**
46+
* The acting user's fellow-organization peers, the user included: the value
47+
* RLS reads as `current_user.org_user_ids` (`sys_user_org_members` and every
48+
* other `id in current_user.org_user_ids` policy). Present on a
49+
* `runAs:'user'` run whose setup seeded it ({@link runOrgUserIds}); a policy
50+
* that reads it compiles `unresolved-variable` and drops out without it, so
51+
* the run would see only the rows its other policies admit. Never set on a
52+
* `runAs:'system'` run, whose `isSystem` short-circuit precedes every policy.
53+
*/
54+
org_user_ids?: string[];
4555
/** Acting user's tenant/org id. */
4656
tenantId?: string;
4757
/**
@@ -108,6 +118,36 @@ export class UnscopedRunDataAccessError extends Error {
108118
*/
109119
export interface RunDataContext extends RunIdentityContext {}
110120

121+
/**
122+
* A flow run's {@link AutomationContext} as run setup leaves it: the context
123+
* the trigger supplied, plus the identity facts the engine seeds for a
124+
* `runAs:'user'` run. `AutomationEngine.resolveRunContext` is the only writer.
125+
*
126+
* `org_user_ids` is the request path's peer list, from the same resolver: the
127+
* automation plugin bridges the engine's grants resolver to
128+
* `@objectstack/core`'s `resolveUserAuthzGrants`, which `resolveAuthzContext`
129+
* also seeds a request's `org_user_ids` through. So it is the members of the
130+
* run's organization (`tenantId`) plus the acting user, or the acting user
131+
* alone when the run has no organization, exactly as a request in that
132+
* organization answers. It rides the run context from setup on: into every
133+
* data node through {@link resolveRunDataContext}, into a `subflow` / `map`
134+
* child with the rest of the parent's context, and through a suspension with
135+
* the persisted context, the way `positions` / `permissions` do.
136+
*/
137+
export interface SeededRunContext extends AutomationContext {
138+
org_user_ids?: string[];
139+
}
140+
141+
/**
142+
* The peer list run setup seeded on `context` ({@link SeededRunContext}), or
143+
* `undefined` when there is none. `undefined` is never widened into a list:
144+
* the policies that read it drop out instead, which narrows (fail closed).
145+
*/
146+
export function runOrgUserIds(context: AutomationContext | undefined): string[] | undefined {
147+
const peers = (context as SeededRunContext | undefined)?.org_user_ids;
148+
return Array.isArray(peers) ? peers : undefined;
149+
}
150+
111151
/**
112152
* Translate a flow run's {@link AutomationContext} into the ObjectQL `context`
113153
* its CRUD nodes must pass, honoring `runAs` (ADR-0049 / #1888):
@@ -132,10 +172,14 @@ export interface RunDataContext extends RunIdentityContext {}
132172
* `flowRunId` are its provenance, and ADR-0118 D1 forbids inventing a
133173
* sentinel or pseudo-user in its place.
134174
* - `runAs:'user'` (default) → the triggering user's identity
135-
* (`{ userId, positions, permissions, tenantId? }`), so the security middleware
136-
* enforces that user's row-level security. The run can never exceed the
137-
* triggering user's grants. Empty `positions` falls back to the platform's
138-
* baseline permission set, exactly like a fresh member's own REST request.
175+
* (`{ userId, positions, permissions, org_user_ids?, tenantId? }`), so the
176+
* security middleware enforces that user's row-level security. The run can
177+
* never exceed the triggering user's grants. Empty `positions` falls back to
178+
* the platform's baseline permission set, exactly like a fresh member's own
179+
* REST request. `org_user_ids` is the peer list run setup seeded
180+
* ({@link SeededRunContext}): with it, `sys_user` answers the acting user and
181+
* their organization's members, as the same user's request does; without
182+
* it, every policy that reads it drops out.
139183
* - neither (an effective `runAs:'user'` run with no trigger user) → **throws**
140184
* {@link UnscopedRunDataAccessError} (#3760). This used to return a
141185
* provenance-only envelope and let the run proceed UNSCOPED — the #1888
@@ -225,6 +269,8 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R
225269
positions: Array.isArray(context.positions) ? context.positions : [],
226270
permissions: Array.isArray(context.permissions) ? context.permissions : [],
227271
};
272+
const orgUserIds = runOrgUserIds(context);
273+
if (orgUserIds) out.org_user_ids = orgUserIds;
228274
if (context.tenantId) out.tenantId = context.tenantId;
229275
if (flowRunId) out.flowRunId = flowRunId;
230276
return out;

0 commit comments

Comments
 (0)