From cf48a538e477376dc3578ca9b5c50601228b2ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:20:53 +0000 Subject: [PATCH 1/3] feat(plugin-security)!: a flow that runs as system writes no identity assignment The data middleware refuses a governed write to sys_user_position, sys_user_permission_set, sys_invitation or sys_member from a context that carries isSystem and a flowRunId, ahead of its isSystem bypass, with PERMISSION_DENIED and the remedy (runAs 'user' for grants, the organization endpoints for invitations and memberships). System writers without a run, reads, other objects and runAs 'user' runs are unchanged; a runAs 'user' run reaches the delegated-administration gate as the user it acts for. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu --- ...5251-flow-system-identity-write-refusal.md | 19 + .../src/delegated-admin-gate.ts | 17 +- ...flow-system-identity-write-refusal.test.ts | 340 ++++++++++++++++++ .../src/flow-system-identity-write-refusal.ts | 107 ++++++ .../plugin-security/src/security-plugin.ts | 11 + ...identity-write-refusal.integration.test.ts | 244 +++++++++++++ .../src/runtime-identity.ts | 6 +- 7 files changed, 740 insertions(+), 4 deletions(-) create mode 100644 .changeset/25251-flow-system-identity-write-refusal.md create mode 100644 packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts create mode 100644 packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts create mode 100644 packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts diff --git a/.changeset/25251-flow-system-identity-write-refusal.md b/.changeset/25251-flow-system-identity-write-refusal.md new file mode 100644 index 00000000000..1b2f6385d1e --- /dev/null +++ b/.changeset/25251-flow-system-identity-write-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security)!: a flow that runs as system writes no identity assignment — position holdings, permission-set grants, invitations and memberships are refused from a flow run's system context + +Clause-②: no (narrowing) +Metadata-protocol: semantic + + + +**BREAKING** for a `runAs: 'system'` flow that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member`, shipped as `minor` under the launch-window convention for breaking changes. No flow shipped in this repository or in hotcrm does: a census of every create, update and delete record node found none aimed at the four objects. + +- **FROM:** the data middleware returned `next()` for every `isSystem` context ahead of its gates, so a `runAs: 'system'` run, whose data context is `isSystem` plus the run's `flowRunId`, wrote these four objects past the ADR-0090 D12 delegated-administration gate and the identity write guard. Its grants carried no `granted_by`, and its invitations and memberships never passed the organization endpoints' checks. +- **TO:** an insert, update, delete, transfer, restore or purge of a row of the four objects from a context with `isSystem` and a `flowRunId` is refused with `PERMISSION_DENIED` (`403`) before the bypass, and nothing is written. The write node fails, and the run's result carries the refusal to its caller. The message names the remedy: `runAs: 'user'` for position holdings and permission-set grants, the organization endpoints for invitations and memberships. This holds at any depth of the flow's own write: a cascade leg or a hook's `ctx.api` write under the same context is refused the same way. +- **Unchanged:** every system writer that carries no `flowRunId` (the seeders, the publish materializer, better-auth and its reconciliation, invitation placement, the organization-administrator grant). A `runAs: 'system'` flow's reads of the four objects, and its writes to every other object. A `runAs: 'user'` flow, which carries a `flowRunId` without `isSystem`: it reaches the D12 gate as the user it acts for, which admits a tenant-level or delegated administrator's grant and records that user as `granted_by`, and refuses anyone else's. +- **Fix:** set the flow's `runAs` to `'user'`, so the grant is judged and attributed as the triggering user's own. Change invitations and memberships through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`. + +The package's exports are unchanged. diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 98039ed8a9d..1c8aa077716 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -48,7 +48,11 @@ * * System/boot writes carry `isSystem` and short-circuit the security * middleware before this gate (seeders, publish materializer, better-auth - * reconciliation are unaffected). + * reconciliation are unaffected). One system context stops short of that + * bypass: a flow run's (`isSystem` with a `flowRunId`), whose writes to these + * tables, `sys_invitation` included, are refused outright + * (`flow-system-identity-write-refusal.ts`) — a `runAs: 'user'` run is judged + * here instead, as the user it acts for. */ import { isGrantActive, securityCatalogReaderOf, type SecurityCatalogReader } from '@objectstack/core'; @@ -171,7 +175,12 @@ const GOVERNED_OBJECTS = new Set([ // site is what matters (AGENTS.md Prime Directive #10). 'sys_member', ]); -const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']); +/** + * The verbs this gate governs. Exported because the flow-origin system refusal + * (`flow-system-identity-write-refusal.ts`) judges the same set: a verb added + * here is refused there too. + */ +export const GOVERNED_OPERATIONS: ReadonlySet = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']); const ANCHOR_POSITIONS = new Set(['everyone', 'guest']); /** @@ -191,7 +200,9 @@ const ANCHOR_POSITIONS = new Set(['everyone', 'guest']); * System writers never reach this gate (`isSystem` short-circuits the security * middleware first) and keep the id or null they wrote: invitation placement * writes its issuer, the organization-admin reconcile the attributed human or - * null, the platform-admin promotion null (ADR-0118 D1 — never a sentinel). + * null, the platform-admin promotion null (ADR-0118 D1 — never a sentinel). A + * flow run's system context writes no row here at all: it is refused ahead of + * the bypass (`flow-system-identity-write-refusal.ts`). */ const GRANTED_BY_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']); diff --git a/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts b/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts new file mode 100644 index 00000000000..d96d6fe983a --- /dev/null +++ b/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25251, ruling 6354966423, letter B] A system context a flow run opened + * writes no identity assignment. + * + * The context a `runAs: 'system'` run hands its data nodes carries `isSystem` + * and the run's `flowRunId` (`resolveRunDataContext`, service-automation). The + * data middleware refuses that context's governed writes to the four + * identity-assignment objects ahead of its `isSystem` bypass, so the D12 gate + * and the organization doors are not skipped by elevation. + * + * ## The contract, per context + * + * - `isSystem` + `flowRunId`, user-triggered or user-less: every governed verb + * on each of the four objects is refused with `PERMISSION_DENIED` (403) and + * a prescription, and the store does not move. + * - `isSystem` without `flowRunId` (a seeder, better-auth, a system service): + * the same writes land, as before. + * - `flowRunId` without `isSystem` (a `runAs: 'user'` run): not judged by the + * refusal. It reaches the D12 gate as the user it acts for: a tenant-level + * administrator's grant is admitted and stamped with that user as + * `granted_by`; a plain member's is refused by the gate, not by the refusal. + * - Reads of the four objects, and writes to every other object, under the + * flow's system context are unchanged. + * + * The first block judges the predicate directly, over every verb the D12 gate + * governs (`transfer`, `restore` and `purge` have no engine verb to drive). The + * rest drive the real `SecurityPlugin` middleware over a real `ObjectQL` + * engine and a real SQL driver, and read the stored rows back. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { GOVERNED_OPERATIONS } from './delegated-admin-gate.js'; +import { + FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS, + assertFlowSystemIdentityWriteAllowed, +} from './flow-system-identity-write-refusal.js'; +import { SysUserPermissionSet, SysUserPosition } from './objects/index.js'; +import { SysPermissionSet, SysPosition, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; + +const pk = { name: 'id', type: 'text' as const, primaryKey: true }; +const text = (name: string) => ({ name, type: 'text' as const }); +/** A minimal stand-in for an object this package does not ship. */ +const inline = (name: string, fields: string[]) => ({ + name, + label: name, + fields: { id: pk, ...Object.fromEntries(fields.map((f) => [f, text(f)])) }, +}); + +/** The baseline every caller holds: nothing. */ +const MEMBER = PermissionSetSchema.parse({ name: 'qa_member', objects: {} }); + +/** ADR-0066 tenant-level admin: the superuser wildcard. */ +const TENANT_ADMIN = PermissionSetSchema.parse({ + name: 'qa_tenant_admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +}); + +const ADMIN = 'usr_admin'; +const PLAIN = 'usr_plain'; +const TARGET = 'usr_target'; + +/** A `runAs: 'system'` run's data context, as `resolveRunDataContext` builds it for a user-triggered run. */ +const FLOW_SYSTEM = { isSystem: true, actor: 'svc:flow:qa_flow', userId: ADMIN, flowRunId: 'run_1' }; +/** The same for a user-less (scheduled) run. */ +const FLOW_SYSTEM_USERLESS = { isSystem: true, actor: 'svc:flow:qa_flow', positions: [], permissions: [], flowRunId: 'run_2' }; +/** A platform writer's own system context: no run. */ +const SEEDER = { isSystem: true }; +/** A `runAs: 'user'` run's data context for a tenant-level administrator. */ +const FLOW_USER_ADMIN = { isSystem: false, userId: ADMIN, positions: [], permissions: ['qa_tenant_admin'], flowRunId: 'run_3' }; +/** A `runAs: 'user'` run's data context for a plain member. */ +const FLOW_USER_PLAIN = { isSystem: false, userId: PLAIN, positions: [], permissions: ['qa_member'], flowRunId: 'run_4' }; + +const opts = (context: Record) => ({ context: { ...context } }) as never; + +/** A row of each of the four objects, insertable by the seeder. */ +const ROWS: Record Record> = { + sys_user_position: (id) => ({ id, user_id: TARGET, position: 'qa_rep', business_unit_id: 'bu_east' }), + sys_user_permission_set: (id) => ({ id, user_id: TARGET, permission_set: 'qa_plain' }), + sys_invitation: (id) => ({ id, email: `${id}@example.test`, role: 'member', status: 'pending', inviter_id: ADMIN }), + sys_member: (id) => ({ id, user_id: TARGET, organization_id: 'org_default', role: 'member' }), +}; + +/** The column an update changes on each object. */ +const EDITS: Record> = { + sys_user_position: { business_unit_id: 'bu_hq' }, + sys_user_permission_set: { permission_set: 'qa_other' }, + sys_invitation: { role: 'admin' }, + sys_member: { role: 'admin' }, +}; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(): Promise { + const engine = new ObjectQL({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } } as never); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.flow-system-identity-write-refusal', + name: 'flow system identity write refusal', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { ...inline('sys_user', ['name', 'primary_business_unit_id']), managedBy: 'better-auth' }, + { ...inline('sys_organization', ['name']), managedBy: 'better-auth' }, + inline('sys_business_unit', ['name', 'parent_business_unit_id', 'organization_id']), + inline('sys_business_unit_member', ['user_id', 'business_unit_id', 'organization_id']), + { ...inline('sys_member', ['user_id', 'organization_id', 'role']), managedBy: 'better-auth' }, + { + ...inline('sys_invitation', ['email', 'role', 'status', 'inviter_id', 'organization_id', 'expires_at']), + managedBy: 'better-auth', + }, + inline('qa_note', ['title', 'organization_id', 'owner_id']), + SysPermissionSet, + SysPosition, + SysPositionPermissionSet, + SysUserPermissionSet, + SysUserPosition, + ], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + // Fixture rows go in past the middleware: setup is not the subject. + const SYS = opts(SEEDER); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization' }, SYS); + await engine.insert('sys_user', [ADMIN, PLAIN, TARGET].map((id) => ({ id, name: id })), SYS); + await engine.insert('sys_business_unit', [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + ], SYS); + await engine.insert('sys_business_unit_member', [{ id: 'bum_target', user_id: TARGET, business_unit_id: 'bu_east' }], SYS); + await engine.insert('sys_permission_set', [ + { id: 'ps_plain', name: 'qa_plain', label: 'Plain', active: true }, + { id: 'ps_other', name: 'qa_other', label: 'Other', active: true }, + ], SYS); + await engine.insert('sys_position', [{ id: 'pos_rep', name: 'qa_rep', label: 'Rep', active: true }], SYS); + await engine.insert('sys_position_permission_set', [{ id: 'pps_rep', position_id: 'pos_rep', permission_set_id: 'ps_plain' }], SYS); + // [ADR-0131 D3/D4] The security catalog the gate reads. + const QA_PKG = 'com.objectstack.qa.flow-system-identity'; + for (const name of ['qa_plain', 'qa_other']) { + engine.registry.registerItem('permission', { name, label: name } as any, 'name' as any, QA_PKG); + } + engine.registry.registerItem('position', { name: 'qa_rep', label: 'Rep', permissionSets: ['qa_plain'] } as any, 'name' as any, QA_PKG); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER, TENANT_ADMIN], + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'qa_member' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + return engine; +} + +/** The stored row, read past the middleware; `null` when there is none. */ +async function stored(engine: ObjectQL, object: string, id: string): Promise | null> { + return ((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) as any) ?? null; +} + +/** The refusal a write met, or `null` when it landed. */ +async function refusal(write: () => Promise): Promise { + try { + await write(); + return null; + } catch (e) { + return e; + } +} + +function expectFlowSystemRefusal(err: any, object: string, operation: string): void { + expect(err, `${operation} on ${object} was not refused`).not.toBe(null); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode ?? err.status).toBe(403); + expect(err.details).toMatchObject({ object, operation }); + expect(err.message).toMatch(/flow that runs as system/); + if (object === 'sys_user_position' || object === 'sys_user_permission_set') { + expect(err.message).toMatch(/runAs to 'user'/); + } else { + expect(err.message).toMatch(/organization endpoints/); + } +} + +const OBJECTS = [...FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS]; + +describe('the predicate: isSystem AND flowRunId, the D12 gate\'s verbs, the four objects', () => { + it('names exactly the four identity-assignment objects', () => { + expect(OBJECTS.sort()).toEqual(['sys_invitation', 'sys_member', 'sys_user_permission_set', 'sys_user_position']); + }); + + for (const object of OBJECTS) { + for (const operation of GOVERNED_OPERATIONS) { + it(`refuses '${operation}' on ${object} from a flow's system context`, () => { + let err: any = null; + try { + assertFlowSystemIdentityWriteAllowed({ object, operation, context: FLOW_SYSTEM }); + } catch (e) { + err = e; + } + expectFlowSystemRefusal(err, object, operation); + }); + } + } + + it('passes a system context without a run, a run without isSystem, reads, and other objects', () => { + for (const object of OBJECTS) { + for (const operation of GOVERNED_OPERATIONS) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: SEEDER })).not.toThrow(); + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: { ...SEEDER, flowRunId: '' } })).not.toThrow(); + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: FLOW_USER_ADMIN })).not.toThrow(); + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation })).not.toThrow(); + } + for (const operation of ['find', 'findOne', 'count', 'aggregate']) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: FLOW_SYSTEM })).not.toThrow(); + } + } + for (const object of ['sys_user', 'sys_business_unit', 'sys_organization', 'qa_note']) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation: 'insert', context: FLOW_SYSTEM })).not.toThrow(); + } + }); +}); + +describe("a flow's system context: governed writes to the four objects are refused through the middleware", () => { + for (const object of OBJECTS) { + for (const [label, context] of [['user-triggered', FLOW_SYSTEM], ['user-less', FLOW_SYSTEM_USERLESS]] as const) { + it(`${object}: insert, update and delete from a ${label} run are refused and the store does not move`, async () => { + const engine = await boot(); + await engine.insert(object, ROWS[object]('seeded'), opts(SEEDER)); + const before = await stored(engine, object, 'seeded'); + + expectFlowSystemRefusal(await refusal(() => engine.insert(object, ROWS[object]('born'), opts(context))), object, 'insert'); + expect(await stored(engine, object, 'born')).toBe(null); + + expectFlowSystemRefusal( + await refusal(() => engine.update(object, EDITS[object], { where: { id: 'seeded' }, context: { ...context } } as never)), + object, + 'update', + ); + expectFlowSystemRefusal( + await refusal(() => engine.delete(object, { where: { id: 'seeded' }, context: { ...context } } as never)), + object, + 'delete', + ); + expect(await stored(engine, object, 'seeded')).toEqual(before); + }); + } + } +}); + +describe('CONTROL: a system context without a run writes the four objects as before', () => { + for (const object of OBJECTS) { + it(`${object}: the seeder's insert, update and delete land`, async () => { + const engine = await boot(); + await engine.insert(object, ROWS[object]('seeded'), opts(SEEDER)); + expect(await stored(engine, object, 'seeded')).toMatchObject({ id: 'seeded' }); + + await engine.update(object, EDITS[object], { where: { id: 'seeded' }, context: { ...SEEDER } } as never); + expect(await stored(engine, object, 'seeded')).toMatchObject(EDITS[object]); + + await engine.delete(object, { where: { id: 'seeded' }, context: { ...SEEDER } } as never); + expect(await stored(engine, object, 'seeded')).toBe(null); + }); + } +}); + +describe("CONTROL: a flow's system context still reads the four objects and writes every other object", () => { + it('reads each of the four objects', async () => { + const engine = await boot(); + for (const object of OBJECTS) { + await engine.insert(object, ROWS[object]('seeded'), opts(SEEDER)); + const rows = (await engine.find(object, { where: { id: 'seeded' }, context: { ...FLOW_SYSTEM } } as never)) as any[]; + expect(rows.map((r) => r.id), object).toEqual(['seeded']); + } + }); + + it('writes an ordinary object and an identity object outside the four', async () => { + const engine = await boot(); + await engine.insert('qa_note', { id: 'note_1', title: 'Note' }, opts(FLOW_SYSTEM)); + await engine.update('qa_note', { title: 'Edited' }, { where: { id: 'note_1' }, context: { ...FLOW_SYSTEM } } as never); + expect(await stored(engine, 'qa_note', 'note_1')).toMatchObject({ title: 'Edited' }); + + await engine.insert('sys_business_unit', { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, opts(FLOW_SYSTEM)); + expect(await stored(engine, 'sys_business_unit', 'bu_west')).toMatchObject({ name: 'west' }); + }); +}); + +describe("a runAs:'user' run's context is judged by the D12 gate as the user it acts for", () => { + for (const object of ['sys_user_position', 'sys_user_permission_set']) { + it(`${object}: a tenant-level administrator's grant is admitted and records that user as granted_by`, async () => { + const engine = await boot(); + await engine.insert(object, ROWS[object]('granted'), opts(FLOW_USER_ADMIN)); + expect(await stored(engine, object, 'granted')).toMatchObject({ id: 'granted', granted_by: ADMIN }); + }); + + it(`${object}: a plain member's grant is refused by the gate, not by the flow-system refusal`, async () => { + const engine = await boot(); + const err = await refusal(() => engine.insert(object, ROWS[object]('granted'), opts(FLOW_USER_PLAIN))); + expect(err, 'the plain member\'s grant landed').not.toBe(null); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.message).not.toMatch(/flow that runs as system/); + // The gate's own refusal, judging the user the run acts for. + expect(err.message).toMatch(/delegated adminScope \(ADR-0090 D12\)/); + expect(err.details).toMatchObject({ object, operation: 'insert', userId: PLAIN }); + expect(await stored(engine, object, 'granted')).toBe(null); + }); + } +}); diff --git a/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts b/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts new file mode 100644 index 00000000000..42ae40f8e4f --- /dev/null +++ b/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25251, ruling 6354966423, letter B] A flow's system elevation does not + * reach identity assignment. + * + * A `runAs: 'system'` run hands its data nodes the context + * `{ isSystem: true, actor: 'svc:flow:NAME', flowRunId, … }` + * (`resolveRunDataContext` in service-automation's `runtime-identity.ts`), and + * the data middleware returns `next()` for `isSystem` ahead of every gate below + * it — the ADR-0090 D12 delegated-administration gate among them. So such a run + * wrote position holdings and permission-set grants that no gate judged and + * that carry no `granted_by`, and invitation and membership rows that never + * passed the invitation door's placement and role-cap checks. + * + * The rows of these four objects decide who holds which authority, and each + * family has a door that judges a person: the D12 gate for the two assignment + * tables, the organization endpoints for invitations and memberships. A flow is + * app-authored automation, and `runAs` declares its authorization posture, not + * an identity (ADR-0073 D2), so elevation does not lift those doors — the same + * carve-out the maintainer made for the stored-metadata tables, whose + * prescription says "Elevation (`runAs`, a system context) does not change + * this." + * + * ## The predicate is the ruling's: `isSystem` AND `flowRunId` + * + * `flowRunId` is provenance, never a principal (`ExecutionContext.flowRunId`), + * and the automation engine stamps it on every run's context. The platform's + * own writers — the seeders, the publish materializer, better-auth and its + * reconciliation, invitation placement, the organization-administrator grant — + * each write under a `{ isSystem: true }` of their own that carries none, so + * their path is unchanged. Truthiness on both, exactly as the bypass reads + * `isSystem`: every context the bypass would admit and a run opened is judged. + * + * ## What is judged + * + * The verbs the D12 gate governs ({@link GOVERNED_OPERATIONS}) on the four + * objects; reads are not judged, so a system flow still reads holdings + * (approval routing does). The operation is judged as the engine hands it to + * the middleware, at any depth: a write that the flow's own write sets off under + * the same context — the `set_null` / `cascade` leg of a delete, an app-authored + * hook body's `ctx.api` write — is the flow's write too, and is refused the same + * way. + * + * A `runAs: 'user'` run carries `flowRunId` WITHOUT `isSystem`. It is not judged + * here: it reaches the D12 gate as the user it acts for, which admits it and + * stamps `granted_by`, or refuses it — the path the prescription names. + * + * The answer is the standard catalog's `PERMISSION_DENIED` (403), the code the + * D12 gate and the identity write guard answer the same writes with; no code is + * minted. The flow's write node fails with it, and the run's result hands it to + * the run's caller as its inner refusal (`code` and `message`). + */ + +import { GOVERNED_OPERATIONS } from './delegated-admin-gate.js'; +import { PermissionDeniedError } from './errors.js'; + +/** The identity-assignment objects a flow run's system context may not write. */ +export const FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS: ReadonlySet = new Set([ + 'sys_user_position', + 'sys_user_permission_set', + 'sys_invitation', + 'sys_member', +]); + +/** + * The two whose door is the D12 gate, so the remedy is `runAs: 'user'`. The + * other two are better-auth's tables: the identity write guard refuses a user + * context's write to them as well, so their remedy is the organization doors. + */ +const ASSIGNMENT_OBJECTS: ReadonlySet = new Set(['sys_user_position', 'sys_user_permission_set']); + +const ASSIGNMENT_PRESCRIPTION = + "Set the flow's runAs to 'user': the delegated-administration gate then judges the user the run acts for, " + + 'and records that user as the grant\'s granted_by.'; + +const MEMBERSHIP_PRESCRIPTION = + 'Invitations and memberships change through the organization endpoints (POST /api/v1/auth/organization/' + + 'invite-member, cancel-invitation, update-member-role, remove-member), which apply the invitation\'s ' + + 'placement and role-cap checks.'; + +/** + * Refuse a governed write to {@link FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS} from a + * system context a flow run opened; return for every other operation. + * + * Called by the data middleware immediately before its `isSystem` bypass, so + * the refused write never reaches the engine's write path. + */ +export function assertFlowSystemIdentityWriteAllowed(opCtx: { + object?: unknown; + operation?: unknown; + context?: { isSystem?: unknown; flowRunId?: unknown } | null; +}): void { + const context = opCtx?.context; + if (!context?.isSystem || !context.flowRunId) return; + const object = typeof opCtx.object === 'string' ? opCtx.object : ''; + if (!FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS.has(object)) return; + const operation = typeof opCtx.operation === 'string' ? opCtx.operation : ''; + if (!GOVERNED_OPERATIONS.has(operation)) return; + throw new PermissionDeniedError( + `[Security] Access denied: '${operation}' on '${object}' from a flow that runs as system ` + + "(runAs: 'system') is refused — position holdings, permission-set grants, invitations and " + + "memberships are governed identity writes, and a flow's system elevation does not lift that. " + + (ASSIGNMENT_OBJECTS.has(object) ? ASSIGNMENT_PRESCRIPTION : MEMBERSHIP_PRESCRIPTION), + { operation, object }, + ); +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a9f8e9414be..68258e636f8 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -29,6 +29,7 @@ import { import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { composeHumanBaselinePermissionSets, PLATFORM_BASELINE_PERMISSION_SET } from './app-default-permission-set.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; +import { assertFlowSystemIdentityWriteAllowed } from './flow-system-identity-write-refusal.js'; import { INVITATION_PLACEMENT_SERVICE, createInvitationPlacementService, @@ -2577,6 +2578,16 @@ export class SecurityPlugin implements Plugin { this.epoch.bump('write'); } + // [#25251, ruling B] One system context stops short of the bypass: a + // flow run's (`isSystem` with a `flowRunId`). Its writes to the four + // identity-assignment objects are refused here, because the gates that + // judge those writes — the ADR-0090 D12 delegated-administration gate + // below, and the identity write guard that keeps invitations and + // memberships on the organization endpoints — all stand down for + // `isSystem`. Every other system write, and every read, passes on to the + // bypass unchanged. See `flow-system-identity-write-refusal.ts`. + assertFlowSystemIdentityWriteAllowed(opCtx); + // System operations bypass security if (opCtx.context?.isSystem) { return next(); diff --git a/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts b/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts new file mode 100644 index 00000000000..f424179be0d --- /dev/null +++ b/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25251, ruling 6354966423, letter B] A `runAs:'system'` flow writes no + * identity assignment; a `runAs:'user'` flow's assignment is judged by the + * delegated-administration gate as the user it acts for. + * + * A system run's data nodes write under `{ isSystem: true, …, flowRunId }` + * (`resolveRunDataContext`), and the security middleware's `isSystem` bypass + * precedes the ADR-0090 D12 gate and the identity write guard. The middleware + * now refuses that context's writes to `sys_user_position`, + * `sys_user_permission_set`, `sys_invitation` and `sys_member` ahead of the + * bypass. Directions decided before running: + * + * - a `runAs:'system'` flow's create, update or delete of one of the four + * fails with `PERMISSION_DENIED` carried to the run's caller, and the store + * does not move: user-triggered and user-less (scheduled) alike; + * - a `runAs:'user'` flow triggered by a tenant-level administrator grants a + * permission set, and the grant records that administrator as `granted_by`; + * triggered by a plain member, the D12 gate refuses it; + * - CONTROL, a platform writer's `{ isSystem: true }` (no run) writes the + * four objects as before; + * - CONTROL, a `runAs:'system'` flow still reads the four objects and still + * writes every other object. + * + * The real stack, as `runas-system-user-hook-grants.integration.test.ts` + * composes it: ObjectKernel, ObjectQLPlugin, driver-sql on better-sqlite3 + * `:memory:`, the identity objects and the real SecurityPlugin, plus the real + * AutomationEngine and its CRUD nodes. The hand-off from the flow's data + * context to the security middleware is the subject, and no single-layer test + * sees it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, 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, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; + +const SYS = { isSystem: true } as const; + +const text = (name: string) => ({ name, label: name, type: 'text' as const }); + +/** An ordinary object: the control that a system flow's other writes are unchanged. */ +const noteObject = { name: 'fsr_note', label: 'Note', fields: { title: text('title') } }; + +/** The deployment's member profile. */ +const memberProfile = PermissionSetSchema.parse({ name: 'fsr_member', label: 'Refusal: member profile', objects: {} }); +/** ADR-0066 tenant-level administrator: the superuser wildcard. */ +const tenantAdmin = PermissionSetSchema.parse({ + name: 'fsr_tenant_admin', label: 'Refusal: tenant admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +}); +/** The set the flows grant. */ +const plainSet = PermissionSetSchema.parse({ name: 'fsr_plain', label: 'Refusal: plain', objects: { fsr_note: { allowRead: true } } }); + +type Node = { id: string; type: string; label: string; config: Record }; + +/** start → one data node → end. */ +const flowOf = (name: string, runAs: 'system' | 'user', node: Omit) => ({ + name, label: name, type: 'autolaunched', runAs, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'data', label: 'Data', ...node }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'data' }, { id: 'e2', source: 'data', target: 'end' }], +}); +const create = (objectName: string, fields: Record) => ({ type: 'create_record', config: { objectName, fields } }); +const update = (objectName: string, id: string, fields: Record) => ({ type: 'update_record', config: { objectName, filter: { id }, fields } }); +const remove = (objectName: string, id: string) => ({ type: 'delete_record', config: { objectName, filter: { id } } }); +const read = (objectName: string, id: string) => ({ type: 'get_record', config: { objectName, filter: { id } } }); + +describe("a runAs:'system' flow writes no identity assignment; a runAs:'user' flow's is judged as the user", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + /** + * The context each operation on the four objects (and the note) carried past + * the security middleware: registered after it, so a refused write is not seen. + */ + const seen: Array<{ object: string; operation: string; context: Record }> = []; + + const WATCHED = new Set(['sys_user_position', 'sys_user_permission_set', 'sys_invitation', 'sys_member', 'fsr_note']); + + /** The rows each target's fixture and flows write; each case grants its own member, so no two collide. */ + const rows = (target = 'usr_target') => ({ + sys_user_permission_set: { user_id: target, permission_set: 'fsr_plain', organization_id: orgA }, + sys_user_position: { user_id: target, position: 'fsr_rep', organization_id: orgA }, + sys_member: { user_id: 'usr_outsider', organization_id: orgA, role: 'member' }, + sys_invitation: { + email: 'invitee@example.test', role: 'admin', status: 'pending', inviter_id: 'usr_admin', + organization_id: orgA, expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }, + }); + + beforeAll(async () => { + 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({ + defaultPermissionSets: [...securityDefaultPermissionSets, memberProfile, tenantAdmin, plainSet], + fallbackPermissionSet: 'fsr_member', + })); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + ql.registry.registerObject(noteObject as any, 'flow-system-refusal-test', 'flow-system-refusal-test'); + ql.registry.registerItem('position', { name: 'fsr_rep', label: 'Rep', permissionSets: ['fsr_plain'] } as any, 'name' as any, 'flow-system-refusal-test'); + await ql.syncSchemas(); + + const orgs = await ql.find('sys_organization', { context: SYS }); + orgA = String(orgs[0].id); + const members = ['usr_admin', 'usr_plain', 'usr_target', 'usr_seeded', 'usr_granted', 'usr_platform']; + for (const id of [...members, 'usr_outsider']) { + await ql.insert('sys_user', { id, name: id, email: `${id}@example.test` }, { context: SYS }); + } + for (const id of members) { + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: orgA, role: 'member' }, { context: SYS }); + } + await ql.insert('sys_user_permission_set', { id: 'ups_admin', user_id: 'usr_admin', permission_set: 'fsr_tenant_admin', organization_id: orgA }, { context: SYS }); + + ql.registerMiddleware(async (opCtx: any, next: any) => { + if (WATCHED.has(opCtx.object)) seen.push({ object: opCtx.object, operation: opCtx.operation, context: { ...(opCtx.context ?? {}) } }); + return next(); + }); + }, 30_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + let flowSeq = 0; + /** Register a one-node flow and run it; triggered by `userId` in organisation A, or user-less when omitted. */ + async function run(runAs: 'system' | 'user', node: Omit, userId?: string) { + const name = `fsr_flow_${++flowSeq}`; + automation.registerFlow(name, flowOf(name, runAs, node) as never); + const trigger = userId ? { userId, tenantId: orgA } : { event: 'schedule', params: {} }; + return automation.execute(name, trigger as never); + } + const stored = async (object: string, where: Record) => + (await ql.find(object, { where, context: SYS })) as any[]; + const lastSeen = (object: string, operation: string) => + [...seen].reverse().find((s) => s.object === object && s.operation === operation); + + function expectRefused(res: any, object: string) { + expect(res.success, `the run succeeded: ${JSON.stringify(res)}`).toBe(false); + expect(res.innerRefusal?.code).toBe('PERMISSION_DENIED'); + expect(res.innerRefusal?.message).toMatch(new RegExp(`on '${object}' from a flow that runs as system`)); + } + + for (const object of ['sys_user_permission_set', 'sys_user_position', 'sys_member', 'sys_invitation'] as const) { + it(`${object}: a runAs:'system' flow's create is refused, user-triggered and user-less, and no row is written`, async () => { + const row = rows()[object]; + const before = (await stored(object, {})).length; + + expectRefused(await run('system', create(object, row), 'usr_admin'), object); + expectRefused(await run('system', create(object, row)), object); + + expect((await stored(object, {})).length).toBe(before); + }); + } + + it("sys_user_permission_set and sys_member: a runAs:'system' flow's update and delete are refused, and the rows do not move", async () => { + await ql.insert('sys_user_permission_set', { id: 'ups_seeded', ...rows('usr_seeded').sys_user_permission_set }, { context: SYS }); + const grant = await stored('sys_user_permission_set', { id: 'ups_seeded' }); + const member = await stored('sys_member', { id: 'mem_usr_plain' }); + + expectRefused(await run('system', update('sys_user_permission_set', 'ups_seeded', { permission_set: 'fsr_tenant_admin' }), 'usr_admin'), 'sys_user_permission_set'); + expectRefused(await run('system', remove('sys_user_permission_set', 'ups_seeded'), 'usr_admin'), 'sys_user_permission_set'); + expectRefused(await run('system', update('sys_member', 'mem_usr_plain', { role: 'owner' }), 'usr_admin'), 'sys_member'); + expectRefused(await run('system', remove('sys_member', 'mem_usr_plain')), 'sys_member'); + + expect(await stored('sys_user_permission_set', { id: 'ups_seeded' })).toEqual(grant); + expect(await stored('sys_member', { id: 'mem_usr_plain' })).toEqual(member); + }); + + it("a runAs:'user' flow triggered by a tenant-level administrator grants the set, recording that administrator as granted_by", async () => { + const res = await run('user', create('sys_user_permission_set', rows('usr_granted').sys_user_permission_set), 'usr_admin'); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(lastSeen('sys_user_permission_set', 'insert')?.context).toMatchObject({ isSystem: false, userId: 'usr_admin' }); + expect(lastSeen('sys_user_permission_set', 'insert')?.context.flowRunId).toBeTruthy(); + + const granted = await stored('sys_user_permission_set', { user_id: 'usr_granted', permission_set: 'fsr_plain' }); + expect(granted.map((r) => r.granted_by)).toEqual(['usr_admin']); + }); + + it("a runAs:'user' flow triggered by a plain member is refused by the delegated-administration gate", async () => { + const before = (await stored('sys_user_permission_set', { granted_by: 'usr_plain' })).length; + const res = await run('user', create('sys_user_permission_set', rows().sys_user_permission_set), 'usr_plain'); + expect(res.success, `the run succeeded: ${JSON.stringify(res)}`).toBe(false); + expect(res.innerRefusal?.code).toBe('PERMISSION_DENIED'); + expect(res.innerRefusal?.message).toMatch(/delegated adminScope \(ADR-0090 D12\)/); + expect(res.innerRefusal?.message).not.toMatch(/flow that runs as system/); + expect((await stored('sys_user_permission_set', { granted_by: 'usr_plain' })).length).toBe(before); + }); + + it('CONTROL: a platform writer without a run writes the four objects as before', async () => { + const r = rows('usr_platform'); + await ql.insert('sys_user_permission_set', { id: 'ups_platform', ...r.sys_user_permission_set }, { context: SYS }); + await ql.insert('sys_user_position', { id: 'up_platform', ...r.sys_user_position }, { context: SYS }); + await ql.insert('sys_member', { id: 'mem_platform', ...r.sys_member }, { context: SYS }); + await ql.insert('sys_invitation', { id: 'inv_platform', ...r.sys_invitation }, { context: SYS }); + await ql.update('sys_member', { role: 'admin' }, { where: { id: 'mem_platform' }, context: SYS } as never); + await ql.delete('sys_invitation', { where: { id: 'inv_platform' }, context: SYS } as never); + + expect(await stored('sys_user_permission_set', { id: 'ups_platform' })).toHaveLength(1); + expect(await stored('sys_user_position', { id: 'up_platform' })).toHaveLength(1); + expect((await stored('sys_member', { id: 'mem_platform' }))[0]?.role).toBe('admin'); + expect(await stored('sys_invitation', { id: 'inv_platform' })).toHaveLength(0); + }); + + it("CONTROL: a runAs:'system' flow still reads the four objects and still writes every other object", async () => { + for (const [object, id] of [ + ['sys_user_permission_set', 'ups_admin'], ['sys_member', 'mem_usr_admin'], + ['sys_user_position', 'up_platform'], ['sys_invitation', 'none'], + ] as const) { + const res = await run('system', read(object, id), 'usr_admin'); + expect(res.success, `${object} read failed: ${JSON.stringify(res)}`).toBe(true); + expect(lastSeen(object, 'findOne')?.context).toMatchObject({ isSystem: true, userId: 'usr_admin' }); + } + + const res = await run('system', create('fsr_note', { title: 'from a system flow' }), 'usr_admin'); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + expect(await stored('fsr_note', { title: 'from a system flow' })).toHaveLength(1); + }); +}); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 31cac353dec..000e91b4a8e 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -210,7 +210,11 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[] * PLUS the trigger's `userId` / `tenantId` when the trigger resolved them * (#5494; after an approval decision `userId` is the decider, the run's * elevation unchanged) — the security middleware short-circuits on `isSystem`, so the - * run reads/writes with full access, bypassing RLS; the `actor` label and + * run reads/writes with full access, bypassing RLS — save its writes to the + * identity-assignment objects (`sys_user_position`, `sys_user_permission_set`, + * `sys_invitation`, `sys_member`), which plugin-security refuses for a system + * context that carries a `flowRunId` (#25251), so assigning authority from a + * flow takes `runAs:'user'` and the delegated-administration gate; the `actor` label and * the carried-through acting user keep those writes attributable in the * audit log and in the rows' own `created_by`/`updated_by` stamps * (ADR-0014 D2, #4366), and the carried org lets the driver's tenant From c28042176af3ef7c91b7b43bdcf760c5ac402226 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:31:11 +0000 Subject: [PATCH 2/3] docs(permissions): the isSystem census names the flow-system identity write refusal check-system-context-census holds every elevation read site to a row on the page; the new refusal reads isSystem, so it gains row 1b. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu --- content/docs/permissions/system-context.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e1872dcc6a5..49441d91f64 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,6 +97,7 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| | 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` | +| 1b | **A flow run's system context is REFUSED governed writes to the identity-assignment objects** — the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and a `flowRunId` (a `runAs: 'system'` flow's data nodes, at any depth of their writes) is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context without a `flowRunId` (the seeders, better-auth and its reconciliation, invitation placement) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` run carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#assertFlowSystemIdentityWriteAllowed` | | 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` | | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask` | From a6bc84f36e2cc168d6bec66d35cd7551928cc3e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:38:06 +0000 Subject: [PATCH 3/3] test(service-automation): the platform-writer control writes its own non-member Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu --- .../runas-system-identity-write-refusal.integration.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts b/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts index f424179be0d..e67017feacb 100644 --- a/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts +++ b/packages/services/service-automation/src/runas-system-identity-write-refusal.integration.test.ts @@ -129,7 +129,8 @@ describe("a runAs:'system' flow writes no identity assignment; a runAs:'user' fl const orgs = await ql.find('sys_organization', { context: SYS }); orgA = String(orgs[0].id); const members = ['usr_admin', 'usr_plain', 'usr_target', 'usr_seeded', 'usr_granted', 'usr_platform']; - for (const id of [...members, 'usr_outsider']) { + // Two non-members: the refused flows aim at one, the platform control writes the other. + for (const id of [...members, 'usr_outsider', 'usr_outsider_platform']) { await ql.insert('sys_user', { id, name: id, email: `${id}@example.test` }, { context: SYS }); } for (const id of members) { @@ -216,7 +217,7 @@ describe("a runAs:'system' flow writes no identity assignment; a runAs:'user' fl const r = rows('usr_platform'); await ql.insert('sys_user_permission_set', { id: 'ups_platform', ...r.sys_user_permission_set }, { context: SYS }); await ql.insert('sys_user_position', { id: 'up_platform', ...r.sys_user_position }, { context: SYS }); - await ql.insert('sys_member', { id: 'mem_platform', ...r.sys_member }, { context: SYS }); + await ql.insert('sys_member', { id: 'mem_platform', ...r.sys_member, user_id: 'usr_outsider_platform' }, { context: SYS }); await ql.insert('sys_invitation', { id: 'inv_platform', ...r.sys_invitation }, { context: SYS }); await ql.update('sys_member', { role: 'admin' }, { where: { id: 'mem_platform' }, context: SYS } as never); await ql.delete('sys_invitation', { where: { id: 'inv_platform' }, context: SYS } as never);