Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/24448-run-context-org-user-ids.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
});

Expand Down
57 changes: 49 additions & 8 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record<string, Record<string, string>> = {
'`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';
Expand Down Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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),
);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/services/service-automation/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading
Loading