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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
---

feat(spec)!: a `runAs: 'system'` hook or a `script` action whose sandboxed body writes an identity-assignment object is refused at save

App-authored automation does not use elevation to write `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member`. The data middleware already refuses that write at run time with `PERMISSION_DENIED` (403), from a system flow run, a hook that runs as system and an action body. Until now every authoring door still accepted a hook or action whose body says so in writing, and the author learned otherwise only from a failed write. The parse now refuses it too:

- `HookSchema` refuses a hook that declares `runAs: 'system'` and carries a sandboxed JS body (`language: 'js'`) whose `source` calls `insert`, `update`, `delete`, `updateMany`, `deleteMany` or `upsert` on `ctx.api.object('<object>')` for one of the four objects, on the handle or on a name the body bound it to.
- `ActionSchema` refuses a `type: 'script'` action whose sandboxed JS body does the same. An action body always runs as system.
- One `custom` issue per object, at `body.source`, naming the object and the route: `runAs: 'user'` (a hook) or a `runAs: 'user'` flow (an action) for position holdings and permission-set grants, so the ADR-0090 D12 delegated-administration gate judges the user; the organization endpoints for invitations and memberships. It surfaces at `os validate`, at `defineStack` (`422 STACK_SCHEMA_INVALID`), at an artifact's parse and at the metadata save door (`422 INVALID_METADATA`).
- `Hook.runAs` now says that elevation does not reach identity assignment, as `FlowSchema.runAs` does.

Unchanged: reads of the four objects, writes to every other object, a hook under `runAs: 'user'` or `'inherit'`, an expression (L1) body, a code `handler`, and the run-time refusal, which stays the enforcement for every elevated write.

Clause-②: no (narrowing) — `HookSchema` and `ActionSchema` refuse a body they used to accept. Nothing is widened.

Metadata-protocol: narrowed (a `runAs: 'system'` hook or a `script` action may no longer carry a sandboxed body that writes an identity-assignment object)

Ruling-ref: 6384657105 (letter A); retriage 6431012701

<!-- adr-0087: registered hook-action-body-system-identity-assignment-write-refused -->

**BREAKING** for an author whose system hook or action body writes one of the four objects. Such a write was already refused when the body ran.

- **Before.** The hook or action saved, and its write failed at run time with `PERMISSION_DENIED`.
- **Now.** The hook or action is refused at save, at `body.source`.
- **Fix.** For a position holding or permission-set grant, set the hook's `runAs` to `'user'`, or move an action's grant to a flow whose `runAs` is `'user'` (an action of `type: 'flow'` runs it as the invoking user). For an invitation or membership, call `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` or `remove-member`.
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const result = HookSchema.parse(data);
| **timeoutMs** | `number` | optional | Maximum execution time in milliseconds before the hook is aborted |
| **timeout** | `never` | optional | [REMOVED] `hook.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that spell theirs, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **onError** | `Enum<'abort' \| 'log'>` | optional (default: `"abort"`) | Error handling strategy. abort: refuse the operation; a blocking hook's abort rolls the write back inside a unit of work, which the write door opens for an after* hook on an object on the default datasource whose driver declares transactions (elsewhere the caller is refused and the row stays stored). log: log the error and continue |
| **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. |
| **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Elevation does not reach identity assignment: a system hook's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED, and a system hook whose body names such a write is refused at save; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the hook acts for), and change invitations and memberships through the organization endpoints. Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
Expand Down
81 changes: 76 additions & 5 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
// conversion chain) has no business in this schema's import graph.
import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects';
import { HookBodySchema } from './hook-body.zod';
// [#25653] The save-time half of the identity-assignment refusal — the set, the
// body-source reading and the refusal text, shared with `ActionSchema`. An
// import-free leaf, so this schema's import graph is unchanged.
import {
identityAssignmentBodyWriteRefusal,
identityAssignmentWritesNamedIn,
} from '../identity/identity-assignment-body-writes';
// Type-only, and it must stay that way: `contracts/` already imports `data/`
// (`contracts/data-engine.ts`), so a VALUE import here would close a runtime
// cycle. `import type` is erased, leaving the edge in the type graph only.
Expand Down Expand Up @@ -134,6 +141,52 @@ function refuseBodyOnStoredMetadataTarget(
});
}

/*
* ── A hook that runs as system does not write identity assignment ───────────
*
* [#25653, ruling record 6384657105 letter A] App-authored automation does not
* use elevation to write `sys_user_position`, `sys_user_permission_set`,
* `sys_invitation` or `sys_member`. The data middleware refuses that write at
* run time with `PERMISSION_DENIED` (`flow-system-identity-write-refusal.ts`,
* plugin-security), whichever way the hook came to be elevated. The parse
* refuses the hook whose `body` says so in writing, so the author meets the
* refusal at every door that parses a hook — the metadata save door,
* `defineStack` / `os validate`, compile, an artifact's parse (the JSON-stage
* hook in `stack.zod.ts` keeps this check through `safeExtend`) — instead of
* in a failed write:
*
* - a hook that declares `runAs: 'system'`, the one elevation its metadata
* states;
* - carrying a sandboxed JS (L2) `body` — an expression (L1) body performs no
* IO, and a code `handler` is not metadata a parse can read;
* - whose `source` names a write (`insert`, `update`, `delete`, `updateMany`,
* `deleteMany`, `upsert`) on `ctx.api.object('<one of the four>')`, called
* on the handle or on a name the body bound it to. One issue per object, at
* `body.source`.
*
* Reads of the four objects stay legal, as they do at run time. The run-time
* refusal stays the enforcement for every elevated write; this is its
* authoring half (the #21565 precedent above).
*/

/**
* The object-level check that refuses a `runAs: 'system'` hook whose sandboxed
* body names a write to an identity-assignment object. See the block above.
*/
function refuseElevatedIdentityAssignmentBodyWrite(
hook: { runAs?: string; body?: { language?: string; source?: unknown } },
ctx: z.RefinementCtx,
): void {
if (hook.runAs !== 'system' || hook.body?.language !== 'js' || typeof hook.body.source !== 'string') return;
for (const object of identityAssignmentWritesNamedIn(hook.body.source)) {
ctx.addIssue({
code: 'custom',
path: ['body', 'source'],
message: identityAssignmentBodyWriteRefusal(object, 'hook'),
});
}
}

/**
* Hook Lifecycle Events
* Defines the interception points in the ObjectQL execution pipeline.
Expand Down Expand Up @@ -462,6 +515,17 @@ export const HookSchema = lazySchema(() => strictObject(
* to `'user'`; the default differs because the situations differ, not the
* word. ⛔ Do not add `'inherit'` to FlowSchema.
*
* Elevation does not reach identity assignment (#25653, ruling record
* 6384657105 letter A — the principle `FlowSchema.runAs` states for a
* flow): under `'system'`, and under an inherited system context, the
* hook's governed writes (insert, update, delete, transfer, restore, purge)
* to `sys_user_position`, `sys_user_permission_set`, `sys_invitation` and
* `sys_member` are refused with `PERMISSION_DENIED`, and a `'system'` hook
* whose sandboxed `body` names such a write is refused at parse. Declare
* `'user'` for position holdings and permission-set grants, so the ADR-0090
* D12 delegated-administration gate judges the user the hook acts for;
* invitations and memberships change through the organization endpoints.
*
* Scope, first cut: `ctx.api` data operations ONLY. `condition` evaluation,
* the `readonly` strip on the hook's own `ctx.input` payload, `ctx.session`
* and the `async` semantics are untouched by this key — the triggering
Expand All @@ -484,6 +548,12 @@ export const HookSchema = lazySchema(() => strictObject(
'A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — ' +
'declare system to make the elevation explicit. This covers any hook fired by a write that carried no user ' +
'(an isSystem plugin/service write; a system-elevated flow node). ' +
"Elevation does not reach identity assignment: a system hook's governed writes (insert, update, delete, " +
'transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member ' +
'are refused with PERMISSION_DENIED, and a system hook whose body names such a write is refused at save; ' +
'declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration ' +
'gate then judges the user the hook acts for), and change invitations and memberships through the ' +
'organization endpoints. ' +
'Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged.',
),

Expand All @@ -494,11 +564,12 @@ export const HookSchema = lazySchema(() => strictObject(
// REJECTED here — the same live 422 that `permission` hit on the ADR-0094
// overlay path before Tier-A declared them (#4001 findings log, entries 2/8).
...MetadataProtectionFields,
// [#21565] The one object-level check: it pairs `object` with `body`, which
// no field-level refine can see together. A schema DERIVED from this one by
// overriding a key must use `.safeExtend()` (zod refuses `.extend()` over a
// refined object), which is what keeps the check on the derived schema too.
}).superRefine(refuseBodyOnStoredMetadataTarget));
// [#21565] The object-level checks: the first pairs `object` with `body`,
// [#25653] the second `runAs` with `body`, pairs no field-level refine can
// see together. A schema DERIVED from this one by overriding a key must use
// `.safeExtend()` (zod refuses `.extend()` over a refined object), which is
// what keeps both checks on the derived schema too.
}).superRefine(refuseBodyOnStoredMetadataTarget).superRefine(refuseElevatedIdentityAssignmentBodyWrite));

/**
* Hook Runtime Context
Expand Down
Loading
Loading