diff --git a/.changeset/25653-app-automation-identity-write-refusal.md b/.changeset/25653-app-automation-identity-write-refusal.md new file mode 100644 index 00000000000..c302f915f48 --- /dev/null +++ b/.changeset/25653-app-automation-identity-write-refusal.md @@ -0,0 +1,26 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/core': patch +--- + +fix(security): a hook that runs as system and an action body are refused writes to position holdings, permission-set grants, invitations and memberships, as a system flow already is + +Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue) +Metadata-protocol: none + + + +**BREAKING** (a served-set narrowing) for app-authored automation that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` under system elevation. A census of the hooks and action bodies in this repository and in HotCRM found none that does. + +A `runAs: 'system'` flow has been refused these writes since 18.1. Two other kinds of app-authored automation run with the same elevation and were not: a hook that declares `runAs: 'system'`, and an action body (`type: 'script'`), which always runs elevated. Their writes skipped the delegated-administration gate (so a grant carried no `granted_by`) and the invitation door's placement and role-cap checks. Both are now refused the same way: + +- An insert, update, delete, transfer, restore or purge of one of the four objects, from a `runAs: 'system'` hook (a sandboxed body or an in-process handler) or from an action body (through `ctx.api` or `ctx.engine`), answers `403 PERMISSION_DENIED`. Nothing is written. A hook's refusal aborts the triggering write under the default `onError: 'abort'`. +- The message names the source and the fix. For position holdings and permission-set grants: set the hook's `runAs` to `'user'`, so the delegated-administration gate judges the user the hook acts for and records them as `granted_by`. An action body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` runs as the invoking user. For invitations and memberships: use the organization endpoints. + +Unchanged: reads of the four objects, and writes to every other object, from the same hook or body. A hook under `runAs: 'user'`, which the ordinary gates judge as its user. The platform's own writers (better-auth and its reconciliation, invitation placement, the seeders, any `sudo()`), which carry no app-automation mark. A system flow's refusal, word for word. + +**What changes for you.** A hook or action body that grants a position or a permission set under elevation now fails. Declare `runAs: 'user'` on the hook, or move an action body's grant into a `runAs: 'user'` flow. Invitations and memberships change through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`. + +`@objectstack/core` exports the mark: `APP_AUTOMATION_ELEVATION`, `markAppAutomationElevation`, `appAutomationElevationOf` and `carryAppAutomationElevation`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 8c901f14aa1..8d405627af9 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +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-authz-middleware.ts#registerAuthzMiddleware` | -| 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` | +| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, a `runAs: 'system'` hook's and an action body's; 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 either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation and by the action body's execution-context builder), 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 with neither (the seeders, better-auth and its reconciliation, invitation placement, any `sudo()`, which never sets the mark) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook 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#elevationSource` | | 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-authz-middleware.ts#registerAuthzMiddleware` | | 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-read-filter.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-field-readers.ts#resolveProjectionFieldMask` | diff --git a/packages/core/src/security/app-automation-elevation.test.ts b/packages/core/src/security/app-automation-elevation.test.ts new file mode 100644 index 00000000000..22a54b967aa --- /dev/null +++ b/packages/core/src/security/app-automation-elevation.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25653] The app-automation elevation mark at its home: how it is set, read + * and carried. Its two producers and its one reader are driven end to end in + * runtime (`app-automation-identity-write-refusal.integration.test.ts`). + */ + +import { describe, it, expect } from 'vitest'; +import { ExecutionContextSchema } from '@objectstack/spec/kernel'; + +import { + APP_AUTOMATION_ELEVATION, + appAutomationElevationOf, + carryAppAutomationElevation, + markAppAutomationElevation, +} from './app-automation-elevation.js'; + +describe('the app-automation elevation mark', () => { + it('is the registry symbol, so an ESM and a CJS copy of this package agree on it', () => { + expect(APP_AUTOMATION_ELEVATION).toBe(Symbol.for('objectstack.core.appAutomationElevation')); + }); + + it('marks a fresh copy and leaves the given context unchanged', () => { + const context = { isSystem: true, userId: 'u1' }; + const marked = markAppAutomationElevation(context, 'hook'); + expect(marked).not.toBe(context); + expect(appAutomationElevationOf(marked)).toBe('hook'); + expect(appAutomationElevationOf(context)).toBe(undefined); + expect(marked).toMatchObject({ isSystem: true, userId: 'u1' }); + }); + + it('rides a spread, as every sudo()-shaped derivation spreads its caller', () => { + const marked = markAppAutomationElevation({ isSystem: true }, 'action'); + expect(appAutomationElevationOf({ ...marked, transaction: {} })).toBe('action'); + }); + + it('reads nothing from a context that carries none, a value outside the two sources, or a non-object', () => { + expect(appAutomationElevationOf({ isSystem: true })).toBe(undefined); + expect(appAutomationElevationOf({ [APP_AUTOMATION_ELEVATION]: 'flow' })).toBe(undefined); + expect(appAutomationElevationOf(null)).toBe(undefined); + expect(appAutomationElevationOf('hook')).toBe(undefined); + }); + + it('cannot be spelled by a JSON payload', () => { + const parsed = JSON.parse(JSON.stringify({ isSystem: true, 'objectstack.core.appAutomationElevation': 'hook' })); + expect(appAutomationElevationOf(parsed)).toBe(undefined); + }); + + it('is dropped by the schema parse, and carried across it', () => { + const marked = markAppAutomationElevation({ isSystem: true, userId: 'u1' }, 'action'); + const parsed = ExecutionContextSchema.parse(marked); + expect(appAutomationElevationOf(parsed)).toBe(undefined); + + const carried = carryAppAutomationElevation(marked, parsed); + expect(appAutomationElevationOf(carried)).toBe('action'); + expect(carried).toMatchObject({ isSystem: true, userId: 'u1' }); + + const unmarked = ExecutionContextSchema.parse({ isSystem: true }); + expect(carryAppAutomationElevation({ isSystem: true }, unmarked)).toBe(unmarked); + }); +}); diff --git a/packages/core/src/security/app-automation-elevation.ts b/packages/core/src/security/app-automation-elevation.ts new file mode 100644 index 00000000000..269da59c1a9 --- /dev/null +++ b/packages/core/src/security/app-automation-elevation.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25653, the #25251 principle for the whole family] The provenance mark an + * app-authored automation's system elevation carries. + * + * ## Why a mark + * + * App-authored automation elevates by declaration: a flow by `runAs: 'system'`, + * a hook by `runAs: 'system'`, and an action body always (it is trusted code, + * #2849 / #3914). Each derives `{ ...caller's context, isSystem: true }`, and so + * does platform code — the better-auth sync, the approval mirrors, the seeders + * — through the same `sudo()`-shaped spread. The security middleware's + * `isSystem` short-circuit cannot tell them apart, and the refusal that stops + * automation from writing the identity-assignment objects + * (`flow-system-identity-write-refusal.ts`, plugin-security) must. A flow run + * is told apart by its `flowRunId`; a hook and an action body carry no run, so + * their elevation carries this mark instead. + * + * ## Where it is set — and where it is never set + * + * Only where app-authored metadata's elevation is derived: + * + * - `'hook'` — a hook's `runAs: 'system'` derivation (`ScopedContext.withRunAs`, + * objectql); + * - `'action'` — an action body's execution-context builder + * (`buildActionExecutionContext`, runtime). + * + * ⛔ Never on `sudo()` itself, nor on any other platform system context: the + * platform's own writes to those objects stay unchanged. Once set, the mark + * rides every spread of the context exactly as `flowRunId` does, so a write the + * hook's or the body's own write sets off under the same context (a nested + * hook, a cascade leg, a `sudo()` taken inside the body) is judged as theirs. + * + * ## Why a symbol, and why it is not an `ExecutionContext` field + * + * It is server-constructed by construction: no request body, query string or + * stored row can spell a symbol, so no caller can claim or shed it. It is an + * in-process provenance fact, never a principal and never an authorization + * grant — its only reader narrows (it refuses; it admits nothing). `Symbol.for` + * rather than a module-local `Symbol()`, because this package ships an ESM and + * a CJS build and a process may load both: the mark must be the same key in + * each. + * + * Being undeclared, the mark does not survive `ExecutionContextSchema.parse`, + * which keeps declared keys only. A producer that parses a marked context + * (`ObjectQL.createContext`) carries it across with + * {@link carryAppAutomationElevation}. + */ + +/** What derived the elevation: a hook's `runAs: 'system'`, or an action body. */ +export type AppAutomationElevation = 'hook' | 'action'; + +/** The context key of the mark. See the module header. */ +export const APP_AUTOMATION_ELEVATION: unique symbol = Symbol.for('objectstack.core.appAutomationElevation'); + +/** + * `context` with the mark added — a fresh object; `context` is not changed. + * Called only at the two derivation sites the module header names. + */ +export function markAppAutomationElevation( + context: T, + source: AppAutomationElevation, +): T & { [APP_AUTOMATION_ELEVATION]: AppAutomationElevation } { + return { ...context, [APP_AUTOMATION_ELEVATION]: source }; +} + +/** The mark `context` carries, or `undefined` for every other context. */ +export function appAutomationElevationOf(context: unknown): AppAutomationElevation | undefined { + if (context === null || typeof context !== 'object') return undefined; + const mark = (context as { [APP_AUTOMATION_ELEVATION]?: unknown })[APP_AUTOMATION_ELEVATION]; + return mark === 'hook' || mark === 'action' ? mark : undefined; +} + +/** + * `to` carrying the mark `from` carries: `to` itself when `from` has none, a + * fresh copy of `to` with the mark otherwise. For a producer that rebuilds a + * context (a schema parse) from one that may be marked. + */ +export function carryAppAutomationElevation(from: unknown, to: T): T { + const mark = appAutomationElevationOf(from); + return mark ? markAppAutomationElevation(to, mark) : to; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index c739997b31e..55d0b4ba1f0 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -453,6 +453,17 @@ export { withoutOperationPrivateKeys, } from './operation-private-keys.js'; +// #25653 — the provenance mark a hook's `runAs: 'system'` and an action body's +// elevation carry, so the identity-assignment refusal tells them from platform +// `sudo()` writes. Set by objectql and runtime, read by plugin-security. +export { + APP_AUTOMATION_ELEVATION, + markAppAutomationElevation, + appAutomationElevationOf, + carryAppAutomationElevation, + type AppAutomationElevation, +} from './app-automation-elevation.js'; + // Ruling of record 6355206139 — the EXTERNAL rung's OWD seam: plugin-security // stamps whether a held grant's RLS speaks, plugin-sharing floors the OWD. export { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d778d28509c..3c7d0253fee 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -159,6 +159,11 @@ import { // [#24497] The ONE grants resolver, the one the request path seeds a // context's grants, peer list and email (#24639) through — see `ScopedContext[SEED_USER_GRANTS]`. resolveUserAuthzGrants, + // [#25653] The app-automation elevation mark: set by a hook's + // `runAs: 'system'` derivation, carried across `createContext`'s parse — + // see `ScopedContext.withRunAs`. + markAppAutomationElevation, + carryAppAutomationElevation, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; @@ -23087,8 +23092,12 @@ export class ObjectQL implements IObjectQLEngine { * await users.find({ where: { status: 'active' } }); */ createContext(ctx: Partial): ScopedContext { + // [#25653] The parse keeps declared keys only, so the app-automation + // elevation mark an action body's envelope carries is carried across it: + // dropped here, the body's `ctx.api` writes would reach the + // identity-assignment refusal unmarked. return new ScopedContext( - ExecutionContextSchema.parse(ctx), + carryAppAutomationElevation(ctx, ExecutionContextSchema.parse(ctx)), this ); } @@ -23382,12 +23391,18 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * `wrapDeclarativeHook`, for the duration of the handler call. `'inherit'` * never reaches here (the wrapper hands the original through by reference). * - * - `'system'` → {@link sudo}: `{ ...triggering context, isSystem: true }`. + * - `'system'` → {@link sudo}-shaped: `{ ...triggering context, isSystem: true }`. * The same envelope the in-process `ctx.api.sudo()` idiom produced, so a * hook that used to elevate by hand elevates identically by declaration * — and now on the sandboxed surface too. `userId` / `tenantId` / * `transaction` ride along: elevation is authorization, not anonymity * (#5494), and the hook's writes stay inside the triggering transaction. + * [#25653] It also carries the app-automation elevation mark + * (`'hook'`, `@objectstack/core`), so the identity-assignment refusal + * (plugin-security) judges the hook's writes to the four identity + * objects as it judges a system flow's. Derived here, ⛔ never in + * {@link sudo}: platform code elevates through `sudo()` and keeps writing + * those objects. * - `'user'` → `{ ...triggering context, isSystem: false }` when the * trigger resolved a user — the pin de-elevates a hook fired by a * `runAs:'system'` flow node or an `isSystem` service write that still @@ -23397,7 +23412,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { * fall-open, never a silent re-badge as system). */ withRunAs(runAs: Exclude, ref: HookRunAsRef): IScopedContext { - if (runAs === 'system') return this.sudo(); + if (runAs === 'system') { + return new ScopedContext( + markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'), + this.engine + ); + } if (!this.executionContext.userId) return new UnscopedHookApi(ref); return new ScopedContext( { ...this.executionContext, isSystem: false }, 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 index d96d6fe983a..85e38a3e099 100644 --- 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 @@ -23,6 +23,12 @@ * `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. + * - [#25653] `isSystem` + the app-automation elevation mark (a hook's + * `runAs: 'system'`, an action body): refused like the flow, each with its + * own subject and prescription; a context carrying a run and a mark is + * answered as the flow. The mark without `isSystem` is not judged. The + * paths that set the mark are driven end to end in runtime + * (`app-automation-identity-write-refusal.integration.test.ts`). * * 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 @@ -31,6 +37,7 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; +import { markAppAutomationElevation } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { PermissionSetSchema } from '@objectstack/spec/security'; @@ -216,6 +223,11 @@ function expectFlowSystemRefusal(err: any, object: string, operation: string): v const OBJECTS = [...FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS]; +/** A `runAs: 'system'` hook's api context, fired by a user's write. */ +const HOOK_SYSTEM = markAppAutomationElevation({ isSystem: true, userId: ADMIN }, 'hook'); +/** An action body's context, invoked by a user. */ +const ACTION_SYSTEM = markAppAutomationElevation({ isSystem: true, userId: ADMIN }, 'action'); + 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']); @@ -338,3 +350,67 @@ describe("a runAs:'user' run's context is judged by the D12 gate as the user it }); } }); + +describe('[#25653] the predicate also reads the app-automation elevation mark', () => { + const ASSIGNMENT = new Set(['sys_user_position', 'sys_user_permission_set']); + + for (const [source, context, subject, prescription] of [ + ['hook', HOOK_SYSTEM, /a hook that runs as system \(runAs: 'system'\)/, /Set the hook's runAs to 'user'/], + ['action', ACTION_SYSTEM, /an action body \(an action body runs as system\)/, /a flow whose runAs is 'user'/], + ] as const) { + for (const object of OBJECTS) { + for (const operation of GOVERNED_OPERATIONS) { + it(`refuses '${operation}' on ${object} from an ${source} elevation`, () => { + let err: any = null; + try { + assertFlowSystemIdentityWriteAllowed({ object, operation, context }); + } catch (e) { + err = e; + } + 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(subject); + expect(err.message).toMatch(ASSIGNMENT.has(object) ? prescription : /organization endpoints/); + }); + } + } + } + + it('answers a context carrying both a run and a mark as the flow', () => { + const both = markAppAutomationElevation({ ...FLOW_SYSTEM }, 'hook'); + for (const object of OBJECTS) { + let err: any = null; + try { + assertFlowSystemIdentityWriteAllowed({ object, operation: 'insert', context: both }); + } catch (e) { + err = e; + } + expectFlowSystemRefusal(err, object, 'insert'); + } + }); + + it('passes the mark without isSystem, reads under the mark, and other objects', () => { + for (const source of ['hook', 'action'] as const) { + const deElevated = markAppAutomationElevation({ isSystem: false, userId: ADMIN, permissions: ['qa_tenant_admin'] }, source); + const elevated = source === 'hook' ? HOOK_SYSTEM : ACTION_SYSTEM; + for (const object of OBJECTS) { + for (const operation of GOVERNED_OPERATIONS) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: deElevated })).not.toThrow(); + } + for (const operation of ['find', 'findOne', 'count', 'aggregate']) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation, context: elevated })).not.toThrow(); + } + } + for (const object of ['sys_user', 'sys_business_unit', 'sys_organization', 'qa_note']) { + expect(() => assertFlowSystemIdentityWriteAllowed({ object, operation: 'insert', context: elevated })).not.toThrow(); + } + } + }); + + it('reads a mark value other than the two sources as no mark', () => { + const forged = { isSystem: true, [Symbol.for('objectstack.core.appAutomationElevation')]: 'platform' }; + expect(() => assertFlowSystemIdentityWriteAllowed({ object: 'sys_member', operation: 'insert', context: forged as never })).not.toThrow(); + }); +}); 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 index 42ae40f8e4f..a01fc4cfce8 100644 --- a/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts +++ b/packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts @@ -2,7 +2,8 @@ /** * [#25251, ruling 6354966423, letter B] A flow's system elevation does not - * reach identity assignment. + * reach identity assignment. [#25653, the same principle for the whole + * family] Nor does a hook's `runAs: 'system'`, nor an action body's elevation. * * A `runAs: 'system'` run hands its data nodes the context * `{ isSystem: true, actor: 'svc:flow:NAME', flowRunId, … }` @@ -46,16 +47,38 @@ * 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 family: a hook that runs as system, and an action body + * + * Two other app-authored elevations carry `isSystem` and no run: a hook's + * `runAs: 'system'` api (`ScopedContext.withRunAs`, objectql) and an action + * body's `ctx.api` / `ctx.engine` (`buildActionExecutionContext`, runtime). + * Each is derived with the app-automation elevation mark + * (`appAutomationElevationOf`, `@objectstack/core`), and this predicate reads + * it beside `flowRunId`: `isSystem` AND (`flowRunId` OR the mark). The mark is + * set only at those two derivations, never on `sudo()`, so a platform writer's + * system context — `sudo()`-derived or bare — carries neither and passes as + * before. Under a hook's `runAs: 'user'` the context loses `isSystem`, and the + * D12 gate judges the user it acts for, as for a `runAs: 'user'` run. + * + * A context carrying both a run and a mark (a `runAs: 'system'` hook fired by a + * system flow's write) is answered as the flow, exactly as before the family + * was added. Each source has its own prescription for the two assignment + * tables: a flow and a hook set `runAs: 'user'`; an action body has no `runAs`, + * so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` + * runs as the invoking user. + * * 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 { appAutomationElevationOf, type AppAutomationElevation } from '@objectstack/core'; + 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. */ +/** The identity-assignment objects app-authored automation's system elevation may not write. */ export const FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS: ReadonlySet = new Set([ 'sys_user_position', 'sys_user_permission_set', @@ -70,18 +93,55 @@ export const FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS: ReadonlySet = new Set([ */ 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.'; +/** Which elevation wrote: a flow run, or an app-automation mark. */ +type ElevationSource = 'flow' | AppAutomationElevation; + +/** How each source is named in the refusal. */ +const SUBJECT: Readonly> = { + flow: "a flow that runs as system (runAs: 'system')", + hook: "a hook that runs as system (runAs: 'system')", + action: 'an action body (an action body runs as system)', +}; + +/** Whose elevation the refusal says does not lift the door. */ +const ELEVATION: Readonly> = { + flow: "a flow's system elevation", + hook: "a hook's system elevation", + action: "an action body's system elevation", +}; + +const ASSIGNMENT_PRESCRIPTION: Readonly> = { + flow: + "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.', + hook: + "Set the hook's runAs to 'user': the delegated-administration gate then judges the user the hook acts for, " + + 'and records that user as the grant\'s granted_by.', + action: + "Make the grant from a flow whose runAs is 'user', which an action of type 'flow' runs as the invoking user: " + + 'the delegated-administration gate then judges that user, and records them 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.'; +/** + * The elevation a system context's write comes from — a flow run, a hook's + * `runAs: 'system'`, an action body — or `undefined` for every other context + * (a platform writer's, a user's). + */ +function elevationSource(context: { isSystem?: unknown; flowRunId?: unknown } | null | undefined): ElevationSource | undefined { + if (!context?.isSystem) return undefined; + if (context.flowRunId) return 'flow'; + return appAutomationElevationOf(context); +} + /** * Refuse a governed write to {@link FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS} from a - * system context a flow run opened; return for every other operation. + * system context a flow run opened, a hook's `runAs: 'system'` derived or an + * action body runs under; 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. @@ -91,17 +151,17 @@ export function assertFlowSystemIdentityWriteAllowed(opCtx: { operation?: unknown; context?: { isSystem?: unknown; flowRunId?: unknown } | null; }): void { - const context = opCtx?.context; - if (!context?.isSystem || !context.flowRunId) return; + const source = elevationSource(opCtx?.context); + if (!source) 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), + `[Security] Access denied: '${operation}' on '${object}' from ${SUBJECT[source]} is refused — ` + + 'position holdings, permission-set grants, invitations and memberships are governed identity writes, ' + + `and ${ELEVATION[source]} does not lift that. ` + + (ASSIGNMENT_OBJECTS.has(object) ? ASSIGNMENT_PRESCRIPTION[source] : MEMBERSHIP_PRESCRIPTION), { operation, object }, ); } diff --git a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts index 10bc2c1433a..6b452b34273 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -304,9 +304,12 @@ export function registerAuthzMiddleware( } // [#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 + // flow run's (`isSystem` with a `flowRunId`) — and [#25653] the rest of + // app-authored automation's, a hook's `runAs: 'system'` and an action + // body's (`isSystem` with the app-automation elevation mark). Their + // 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 diff --git a/packages/runtime/src/action-body-identity.test.ts b/packages/runtime/src/action-body-identity.test.ts index 5c03cc400a8..3eb5843c6ce 100644 --- a/packages/runtime/src/action-body-identity.test.ts +++ b/packages/runtime/src/action-body-identity.test.ts @@ -19,6 +19,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core'; import { HttpDispatcher } from './http-dispatcher.js'; import { buildActionApi, @@ -112,7 +113,12 @@ describe('#3914 — buildActionExecutionContext', () => { }); it('yields a usable elevated context for an anonymous / self-invoked call', () => { - expect(buildActionExecutionContext(undefined)).toEqual({ isSystem: true }); + expect(buildActionExecutionContext(undefined)).toEqual(markAppAutomationElevation({ isSystem: true }, 'action')); + }); + + it('[#25653] carries the action-body elevation mark, so the identity-assignment refusal sees the body', () => { + expect(appAutomationElevationOf(buildActionExecutionContext({ userId: 'user_42' }))).toBe('action'); + expect(appAutomationElevationOf(buildActionExecutionContext(undefined))).toBe('action'); }); }); @@ -172,7 +178,7 @@ describe('#3914 — ctx.api (buildActionApi)', () => { }, }; const api = buildActionApi(deps, ql, { userId: 42 }); - expect(api.__context).toEqual({ isSystem: true }); + expect(api.__context).toEqual(markAppAutomationElevation({ isSystem: true }, 'action')); }); it('returns undefined for an engine with no createContext (sandbox fallback stays in charge)', () => { diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index ea61abd650e..df33d88ceb4 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -37,6 +37,9 @@ import { checkApiExposure, type ApiExposureDecision, type ObjectApiDef } from '. // door's and the MCP stdio bridge's, imported rather than re-walked so this door // cannot disagree with them about which conditions a filter holds. import { relationConditionSites } from '@objectstack/core'; +// [#25653] The app-automation elevation mark an action body's context carries +// — see `buildActionExecutionContext`. +import { markAppAutomationElevation } from '@objectstack/core'; // [#9446] The ONE #9378 status table. Imported rather than re-read here: this // door's blanket `FLOW_FAILED` was the second of three readings of one engine // result, and a second definition of the rule is what let the doors diverge. @@ -1964,10 +1967,19 @@ export function buildActionSession(_deps: ActionExecutionDeps, ec: any): ActionS * `updated_by`, `tenantId` stamps the org column and drives driver-level * tenant isolation, `transaction` joins an open transaction — instead of the * unattributable, org-less rows a bare `{ isSystem: true }` would write. + * + * [#25653] The elevation also carries the app-automation elevation mark + * (`'action'`, `@objectstack/core`): an action body is app-authored + * automation, and the identity-assignment refusal (plugin-security) judges its + * writes to the four identity objects as it judges a system flow's — through + * `ctx.api` (the mark is carried across `createContext`'s parse) and + * `ctx.engine` alike. This builder serves every handler an action door + * dispatches, a host code handler included; a census of the handlers on + * `main` found none that writes those objects. */ export function buildActionExecutionContext(ec: any): Record { const base = ec && typeof ec === 'object' ? { ...(ec as Record) } : {}; - return { ...base, isSystem: true }; + return markAppAutomationElevation({ ...base, isSystem: true }, 'action'); } /** @@ -1996,9 +2008,10 @@ export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): an return serveStoredMetadataReadsThrough(ql.createContext(buildActionExecutionContext(ec)), ql); } catch { // A malformed caller envelope must not sink the action — fall back to - // the bare elevated context (the same shape hooks default to). + // the bare elevated context (the same shape hooks default to), marked + // as the body's elevation like every other (#25653). try { - return serveStoredMetadataReadsThrough(ql.createContext({ isSystem: true }), ql); + return serveStoredMetadataReadsThrough(ql.createContext(buildActionExecutionContext(undefined)), ql); } catch { return undefined; } diff --git a/packages/runtime/src/app-automation-identity-write-refusal.integration.test.ts b/packages/runtime/src/app-automation-identity-write-refusal.integration.test.ts new file mode 100644 index 00000000000..4dc9eae61cc --- /dev/null +++ b/packages/runtime/src/app-automation-identity-write-refusal.integration.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25653, the #25251 principle for the whole family] App-authored automation + * does not use its system elevation to write the four identity-assignment + * objects — a hook that runs as system and an action body, as well as a flow. + * + * A `runAs: 'system'` hook's `ctx.api` and an action body's `ctx.api` / + * `ctx.engine` write under `isSystem` with no `flowRunId`, so the refusal + * #25251 put ahead of the security middleware's `isSystem` short-circuit did + * not see them. Each now carries the app-automation elevation mark + * (`@objectstack/core`), set where the elevation is derived — the hook's + * `runAs` derivation (`ScopedContext.withRunAs`, objectql) and the action + * body's execution-context builder (`buildActionExecutionContext`) — and the + * one predicate (`flow-system-identity-write-refusal.ts`, plugin-security) + * reads it. + * + * ## The contract, per path + * + * - a `runAs: 'system'` hook, as a sandboxed body and as an in-process + * handler, fired by an ordinary user's write: every governed write it makes + * to each of the four objects is refused with `PERMISSION_DENIED` (403) and + * the prescription, and the store does not move; + * - an action body (`type: 'script'`, L2), through `ctx.api` and through + * `ctx.engine`: the same; + * - the same hook under `runAs: 'user'`: not judged by the refusal — the + * ordinary gates judge the user it acts for, and refuse a plain member; + * - platform code's `sudo()` and a platform writer's own system context: the writes land, + * because neither carries the mark (`sudo()` never sets it). + * + * The system flow's own refusal is pinned, unchanged, beside the predicate + * (`flow-system-identity-write-refusal.test.ts`, plugin-security). + * + * Real `ObjectQL`, a real SQL driver, the real `SecurityPlugin` middleware and + * the real QuickJS runner; the store is read back past the middleware. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; +import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; +import { buildActionApi, buildActionEngineFacade, buildActionExecutionContext } from './action-execution.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[], extra: Record = {}) => ({ + name, + label: name, + fields: { id: pk, ...Object.fromEntries(fields.map((f) => [f, text(f)])) }, + ...extra, +}); + +/** The triggering user's baseline: their own object, nothing of the four. */ +const MEMBER = PermissionSetSchema.parse({ + name: 'qa_member', + objects: { qa_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +}); + +/** An ordinary user, unelevated. */ +const PLAIN = { userId: 'usr_plain', tenantId: 'org_default', positions: [], permissions: ['qa_member'], isSystem: false }; + +/** A platform writer's own system context: no run, no mark (the single-tenancy posture wants its organization). */ +const PLATFORM = { isSystem: true, tenantId: 'org_default' }; + +/** A row of each of the four objects. */ +const ROWS: Record Record> = { + sys_user_position: (id) => ({ id, user_id: 'usr_target', position: 'qa_rep', business_unit_id: 'bu_east' }), + sys_user_permission_set: (id) => ({ id, user_id: 'usr_target', permission_set: 'qa_plain' }), + sys_invitation: (id) => ({ id, email: `${id}@example.test`, role: 'member', status: 'pending', inviter_id: 'usr_plain' }), + sys_member: (id) => ({ id, user_id: 'usr_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 OBJECTS = Object.keys(ROWS); +const VERBS = ['insert', 'update', 'delete'] as const; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(): Promise { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const engine = new ObjectQL({ logger } 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.app-automation-identity-write-refusal', + name: 'app automation identity write refusal', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + inline('sys_user', ['name'], { managedBy: 'better-auth' }), + inline('sys_organization', ['name'], { managedBy: 'better-auth' }), + inline('sys_member', ['user_id', 'organization_id', 'role'], { managedBy: 'better-auth' }), + inline('sys_invitation', ['email', 'role', 'status', 'inviter_id', 'organization_id'], { managedBy: 'better-auth' }), + inline('sys_user_position', ['user_id', 'position', 'business_unit_id', 'granted_by']), + inline('sys_user_permission_set', ['user_id', 'permission_set', 'granted_by']), + inline('qa_note', ['title', 'organization_id', 'owner_id']), + ], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER], + }, + }; + const ctx = { + logger, + 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); + engine.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'qa' })); + return engine; +} + +/** Seed one row of `object` past the middleware: setup is not the subject. */ +async function seed(engine: ObjectQL, object: string): Promise> { + await engine.insert(object, ROWS[object]('seeded'), { context: { ...PLATFORM } } as never); + return (await stored(engine, object, 'seeded'))!; +} + +/** 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; + } +} + +/** + * The refusal, as the caller of the triggering write or of the action meets + * it. A hook's refusal reaches the caller through the hook's own error relay, + * so the code is read from the error itself or, when the relay wraps, from its + * `cause` chain. + */ +function rootRefusal(err: any): any { + let e = err; + for (let i = 0; e && i < 5; i++) { + if (e.code === 'PERMISSION_DENIED') return e; + e = e.cause; + } + return err; +} + +function expectRefused(err: any, object: string, operation: string, source: 'hook' | 'action'): void { + expect(err, `${operation} on ${object} from the ${source} was not refused`).not.toBe(null); + const root = rootRefusal(err); + expect(root.code).toBe('PERMISSION_DENIED'); + expect(root.statusCode ?? root.status).toBe(403); + expect(String(err.message)).toMatch( + source === 'hook' ? /a hook that runs as system/ : /an action body \(an action body runs as system\)/, + ); + if (object === 'sys_user_position' || object === 'sys_user_permission_set') { + expect(String(err.message)).toMatch(source === 'hook' ? /Set the hook's runAs to 'user'/ : /a flow whose runAs is 'user'/); + } else { + expect(String(err.message)).toMatch(/organization endpoints/); + } +} + +/** The source of a body that performs `verb` on the seeded (or a new) row of `object`. */ +function bodySource(object: string, verb: (typeof VERBS)[number]): string { + const repo = `ctx.api.object(${JSON.stringify(object)})`; + if (verb === 'insert') return `await ${repo}.insert(${JSON.stringify(ROWS[object]('born'))});`; + if (verb === 'update') return `await ${repo}.update(${JSON.stringify(EDITS[object])}, { where: { id: 'seeded' } });`; + return `await ${repo}.delete({ where: { id: 'seeded' } });`; +} + +/** Bind a hook on `qa_note` after-insert, fire it by the plain user's own write, return the refusal. */ +async function fireHook( + engine: ObjectQL, + runAs: 'system' | 'user', + object: string, + verb: (typeof VERBS)[number], + surface: 'body' | 'handler', +): Promise { + const hook: Record = { name: 'qa_identity_hook', object: 'qa_note', events: ['afterInsert'], runAs }; + if (surface === 'body') { + hook.body = { language: 'js', source: bodySource(object, verb), capabilities: ['api.read', 'api.write'] }; + } else { + hook.handler = async (hctx: any) => { + const repo = hctx.api.object(object); + if (verb === 'insert') await repo.insert(ROWS[object]('born')); + else if (verb === 'update') await repo.update(EDITS[object], { where: { id: 'seeded' } }); + else await repo.delete({ where: { id: 'seeded' } }); + }; + } + bindHooksToEngine(engine, [hook as any], { packageId: 'qa' }); + return refusal(() => engine.insert('qa_note', { id: 'note_1', title: 'Note' }, { context: { ...PLAIN } } as never)); +} + +/** Invoke an action body as an action door does: `ctx.api`, `ctx.engine` and `executionContext` from the caller's envelope. */ +async function runAction(engine: ObjectQL, source: string): Promise { + const handler = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'qa' })({ + name: 'qa_identity_action', + object: 'qa_note', + type: 'script', + body: { language: 'js', source, capabilities: ['api.read', 'api.write'] }, + }); + engine.registerAction('qa_note', 'qa_identity_action', handler!); + const ec = { ...PLAIN }; + return refusal(() => + engine.executeAction('qa_note', 'qa_identity_action', { + api: buildActionApi({} as never, engine, ec), + engine: buildActionEngineFacade({} as never, engine, ec), + executionContext: buildActionExecutionContext(ec), + params: { objectName: 'qa_note' }, + }), + ); +} + +describe("a runAs:'system' hook fired by an ordinary user's write: governed writes to the four objects are refused", () => { + for (const surface of ['body', 'handler'] as const) { + for (const object of OBJECTS) { + for (const verb of VERBS) { + it(`${surface}: ${verb} on ${object} is refused and the store does not move`, async () => { + const engine = await boot(); + const before = await seed(engine, object); + + expectRefused(await fireHook(engine, 'system', object, verb, surface), object, verb, 'hook'); + + expect(await stored(engine, object, 'seeded')).toEqual(before); + expect(await stored(engine, object, 'born')).toBe(null); + // The hook aborted the triggering write too (`onError: 'abort'`). + expect(await stored(engine, 'qa_note', 'note_1')).toBe(null); + }, 30000); + } + } + } + + it('the same hook still writes every other object', async () => { + const engine = await boot(); + bindHooksToEngine(engine, [{ + name: 'qa_other_hook', object: 'qa_note', events: ['afterInsert'], runAs: 'system', + body: { + language: 'js', + source: `await ctx.api.object('qa_note').update({ title: 'stamped' }, { where: { id: 'note_1' } });`, + capabilities: ['api.read', 'api.write'], + }, + } as any], { packageId: 'qa' }); + await engine.insert('qa_note', { id: 'note_1', title: 'Note' }, { context: { ...PLAIN } } as never); + expect(await stored(engine, 'qa_note', 'note_1')).toMatchObject({ title: 'stamped' }); + }, 30000); +}); + +describe('an action body: governed writes to the four objects are refused', () => { + for (const object of OBJECTS) { + for (const verb of VERBS) { + it(`ctx.api: ${verb} on ${object} is refused and the store does not move`, async () => { + const engine = await boot(); + const before = await seed(engine, object); + + expectRefused(await runAction(engine, `${bodySource(object, verb)} return 1;`), object, verb, 'action'); + + expect(await stored(engine, object, 'seeded')).toEqual(before); + expect(await stored(engine, object, 'born')).toBe(null); + }, 30000); + } + + it(`ctx.engine: insert, update and delete on ${object} are refused and the store does not move`, async () => { + const engine = await boot(); + const before = await seed(engine, object); + const facade = buildActionEngineFacade({} as never, engine, { ...PLAIN }); + + expectRefused(await refusal(() => facade.insert(object, ROWS[object]('born'))), object, 'insert', 'action'); + expectRefused(await refusal(() => facade.update(object, 'seeded', EDITS[object])), object, 'update', 'action'); + expectRefused(await refusal(() => facade.delete(object, 'seeded')), object, 'delete', 'action'); + + expect(await stored(engine, object, 'seeded')).toEqual(before); + expect(await stored(engine, object, 'born')).toBe(null); + }, 30000); + } + + it('the same body still reads the four objects and writes every other object', async () => { + const engine = await boot(); + for (const object of OBJECTS) await seed(engine, object); + const err = await runAction( + engine, + `const seen = []; + for (const o of ${JSON.stringify(OBJECTS)}) seen.push((await ctx.api.object(o).find({ where: { id: 'seeded' } })).length); + await ctx.api.object('qa_note').insert({ id: 'note_2', title: String(seen.join(',')) }); + return 1;`, + ); + expect(err).toBe(null); + expect(await stored(engine, 'qa_note', 'note_2')).toMatchObject({ title: '1,1,1,1' }); + }, 30000); +}); + +describe("CONTROL: the same hook under runAs:'user' takes the ordinary gates", () => { + for (const object of OBJECTS) { + it(`${object}: the plain member's insert is refused by the ordinary gates, not by the elevation refusal`, async () => { + const engine = await boot(); + const err = await fireHook(engine, 'user', object, 'insert', 'body'); + expect(err, `the plain member's ${object} insert landed`).not.toBe(null); + expect(rootRefusal(err).code).toBe('PERMISSION_DENIED'); + expect(String(err.message)).not.toMatch(/runs as system/); + expect(await stored(engine, object, 'born')).toBe(null); + }, 30000); + } +}); + +describe('CONTROL: platform system writes carry no mark and still land', () => { + for (const object of OBJECTS) { + it(`${object}: a sudo() derived from a user context, and a bare system context, insert, update and delete`, async () => { + const engine = await boot(); + const sudo = engine.createContext({ ...PLAIN }).sudo().object(object); + await sudo.insert(ROWS[object]('by_sudo')); + expect(await stored(engine, object, 'by_sudo')).toMatchObject({ id: 'by_sudo' }); + await sudo.update(EDITS[object], { where: { id: 'by_sudo' } }); + expect(await stored(engine, object, 'by_sudo')).toMatchObject(EDITS[object]); + await sudo.delete({ where: { id: 'by_sudo' } }); + expect(await stored(engine, object, 'by_sudo')).toBe(null); + + await engine.insert(object, ROWS[object]('by_system'), { context: { ...PLATFORM } } as never); + expect(await stored(engine, object, 'by_system')).toMatchObject({ id: 'by_system' }); + }, 30000); + } +});