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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/25653-app-automation-identity-write-refusal.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the run-time answer to a hook or action body's elevated write to four platform objects; the save-time refusal and its semantic entry are the spec half of the same card. -->

**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`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/security/app-automation-elevation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
83 changes: 83 additions & 0 deletions packages/core/src/security/app-automation-elevation.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(
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<T extends object>(from: unknown, to: T): T {
const mark = appAutomationElevationOf(from);
return mark ? markAppAutomationElevation(to, mark) : to;
}
11 changes: 11 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 23 additions & 3 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -23087,8 +23092,12 @@ export class ObjectQL implements IObjectQLEngine {
* await users.find({ where: { status: 'active' } });
*/
createContext(ctx: Partial<ExecutionContext>): 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
);
}
Expand Down Expand Up @@ -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
Expand All @@ -23397,7 +23412,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
* fall-open, never a silent re-badge as system).
*/
withRunAs(runAs: Exclude<HookRunAs, 'inherit'>, 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 },
Expand Down
Loading
Loading