Skip to content

Commit f72ce93

Browse files
fix(service-automation): a runAs:'user' flow sees its organization's members in sys_user, as the user's own request does (#24495)
Fixes #24448 Clause-②: yes (widening: the exported `RunIdentityContext` / `RunDataContext` type gains the optional `org_user_ids` member, the key `ExecutionContext` already declares; no accepted input moves) ## What was wrong A `runAs: 'user'` flow's data context carried no `org_user_ids`. `sys_user`'s shipped member scope is two policies in `member_default`: `sys_user_self` (`id == current_user.id`) and `sys_user_org_members` (`id in current_user.org_user_ids`). With the peer list absent, the second compiles `unresolved-variable` and drops out. So a `get_record` on `sys_user` in such a flow answered the acting user's own row only, while the same user's request answered their organization's members too. ## Failing probe first `packages/services/service-automation/src/runas-user-org-peers.integration.test.ts` boots the real stack: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 `:memory:`, the identity objects, the real SecurityPlugin with its shipped default permission sets, and the real AutomationEngine with its CRUD nodes. Fixture: organization A (the Default Organization) and organization B. `usr_me` belongs to A and B and acts in A; `usr_peer` belongs to A; `usr_other` belongs to B only; `usr_loner` belongs to none. Committed alone as `6ca644728` and run against the unchanged source: | case | request path (control) | flow before the fix | |:--|:--|:--| | acting in A, record-change shape `{ userId, tenantId }` | `usr_me, usr_peer` | `expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]` | | acting in A, the doors' shape (positions and permission sets already resolved) | `usr_me, usr_peer` | `expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]` | | no organization `{ userId }` | `usr_me` | `usr_me` (already equal) | The control context is `resolveUserAuthzGrants(ql, userId, { tenantId })` copied the way `resolveAuthzContext` copies it onto a request. ## The change - **Run setup seeds the peer list** (`AutomationEngine.resolveRunContext`, `engine.ts`). For a `runAs: 'user'` run with a user, the engine asks its grants resolver whenever the context carries no `org_user_ids` yet, not only when it carries no `permissions`. When permissions were already forwarded (the REST trigger, action and endpoint doors forward them), ONLY the peer list is taken from the answer: the forwarded positions and permission sets stay verbatim, so an agent's narrowed ceiling is not re-broadened. A context that already carries a list (a `subflow` / `map` child, a resumed leg) keeps it and resolves nothing. - **The bridge passes the resolver's list on** (`plugin.ts`). The plugin's grants resolver already called `resolveUserAuthzGrants`, the resolver the request path seeds `org_user_ids` through, and dropped the field. It now returns it. `FlowUserGrants` gains an optional `org_user_ids`. - **`resolveRunDataContext` stays the single mapping** (`runtime-identity.ts`). Its `runAs: 'user'` branch copies the seeded list onto the data context (`RunIdentityContext.org_user_ids`, optional), so every data node and the lookup expander get it. No list is invented when none was seeded: the policies that read it drop out, which narrows. `runAs: 'system'` is unchanged. - The `[runAs]` warning on a resolver failure now says which half degraded (baseline member permissions, or only the peer list). The existing sentence `baseline member permissions (not elevated)` is kept for the full case. ## Mechanism assumptions, measured - **A1** holds: the `const out: RunIdentityContext` block had no `org_user_ids`; the probe above is the failing reading. - **A2** refined: no trigger surface forwards `org_user_ids` (`buildAutomationContext` and `dispatchFlowAction` in `@objectstack/runtime` forward positions/permissions only when non-empty, the record-change trigger forwards `userId` + `tenantId`). So carrying a trigger's value could fix nothing on its own; the engine seeds at its one run-setup point for every producer. The approvals mirror's hand-built `{ isSystem: true, userId }` write reaches a flow through the record-change trigger as `{ userId }` (plus `tenantId` when the hook session has one), which the same seeding covers. - **A3** holds: the resolver is async and engine-bound, so the seed is at run setup (`resolveRunContext`), not per node. - **A4** holds, see "Hook path" below. - **A5** holds: the list is `sys_member` rows with `organization_id = tenantId` plus the user, from the same `resolveUserAuthzGrants` call the request path makes. The fixture pins it: `usr_other` shares organization B with `usr_me` and is still absent when the run acts in A. ## Ablations (each committed first, mutated through `scripts/ablation-replace.mjs`, restored to the HEAD blob with `git diff HEAD` empty) 1. Drop the copy in `resolveRunDataContext` (`if (orgUserIds) out.org_user_ids = orgUserIds;` deleted): both organization cases red, `expected [ 'usr_me' ] to deeply equal [ 'usr_me', 'usr_peer' ]`; control and no-organization stay green. 2. The "nothing beyond" pin, with the forbidden behaviour put back: the bridge returns the union of the user's peers across every organization they belong to. Three cases red: `expected [ 'usr_me', 'usr_other', 'usr_peer' ] to deeply equal [ 'usr_me', 'usr_peer' ]` (twice) and `... to deeply equal [ 'usr_me' ]` for the no-organization run. 3. Seed only when permissions are absent (the old condition): the doors' shape goes red in the integration file, and the two unit pins for that shape in `crud-runas.test.ts` go red. ## Hook path: probed, not in this PR A temporary probe (not committed) bound a `runAs: 'user'` hook that reads `sys_user` through `ctx.api` on the same real stack: ``` PROBE request-fired hook saw: ["usr_me","usr_peer"] PROBE mirror-fired hook saw: ["usr_me"] ``` The second write was the approvals-mirror shape `{ isSystem: true, userId, tenantId }`. `ScopedContext.withRunAs('user')` (`packages/objectql/src/engine.ts`) pins `{ ...executionContext, isSystem: false }`, so from a hand-built context the hook carries no positions, no permission sets and no `org_user_ids`. Repairing it needs an async resolution through the core resolver in `engine.ts` or `hook-wrappers.ts`, or the producers seeding the envelope in `plugin-approvals`. Neither is inside the rider surface the claim declared (`hook-run-as.ts` and its test), so the rider is cut and reported for `domain:engine`. ## Tests and gates (head `304dea50d`, which merges `origin/main` at `ae6bd1a69`) - `pnpm --filter @objectstack/service-automation typecheck`: exit 0 (`tsc --noEmit`, then `check:test-typecheck: OK`). - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2`: `Test Files 190 passed (190)`, `Tests 2541 passed (2541)`. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 68 commands): 67 exit 0; `pnpm check:dual-build-cjs-loads` exit 3, `PREREQUISITE NOT MET` (it reads every package's `dist/`), NOT MEASURED here and left to `Lint & Repo Gates`. `--ran` reconciliation: `68 derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED`. - `test:repo` is not run locally: the diff edits no repo-project test, and `Test Core` runs it. - `origin/main` has moved 5 commits past the merge base since; none touches `service-automation` or the run identity path, so the branch is not re-merged before CI. ## Acceptance notes - **Where the list rides.** It rides the run's `AutomationContext` object under `org_user_ids`, typed by a service-automation-local `SeededRunContext`, because the claim's file surface excludes `packages/spec`. The alternative is declaring the key on `AutomationContext` in `@objectstack/spec/contracts`, which would let the doors forward the request's list and skip the re-resolution below. Correctness does not need it. Carrier: none. - **Cost.** A run started from the REST trigger, action or endpoint doors now pays one grants resolution at setup (`resolveUserAuthzGrants`, cache bypassed as the existing bridge already rules). The record-change path already paid it. - **Runs suspended before this release** resume without the list (a resume does not re-run run setup) and keep today's answer, their own `sys_user` row, until they end. - **Persistence.** A paused `runAs: 'user'` run's `sys_automation_run.context_json` now carries the peer list beside the positions and permission sets it already carried. - **Organization vetting.** The request path vets a claimed organization against the user's memberships under a walled posture; the run takes the trigger's `tenantId` as handed. Every user-triggered door hands over an already-vetted one. Read-only inference, not probed. - **Run context vs request context beyond this card.** `accessible_org_ids`, `systemPermissions` and `posture` are not carried into a `runAs: 'user'` run's data context either; the `group` posture's wall reads `accessible_org_ids`. Read-only, not probed. Carrier: none. - **Clause-②** stays `no` as claimed. The only type movement is two optional members: `RunIdentityContext.org_user_ids` and `FlowUserGrants.org_user_ids`. ## Seat's append (patch round 1) `domain:services` seat 2 re-judged `Clause-②` from the diff: `@objectstack/service-automation` is `access: public` and its index re-exports `RunIdentityContext` / `RunDataContext`, so the new optional member is an additive widening of the published surface. Under the Check Changeset step's WHICH LEVEL rule (ruled 2026-09-04 on #15294) that takes `minor`. The declaration above and the changeset level are corrected in this round; the last Acceptance-notes line, which says it stays `no`, is superseded by this append. --- _Generated by [Claude Code](https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8eac321 commit f72ce93

7 files changed

Lines changed: 371 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': minor
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-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves)

‎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
});

0 commit comments

Comments
 (0)