From 4f0a7f3becefc5fb513f1fa0092213fd09fa7797 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:50:50 +0000 Subject: [PATCH 1/2] feat(spec)!: refuse at save a system hook or script action body that writes an identity-assignment object Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzcHS6dL9h6WLdsGCH --- ...-body-identity-assignment-write-refused.md | 28 +++ packages/spec/src/data/hook.zod.ts | 81 ++++++- .../identity-assignment-body-writes.test.ts | 210 ++++++++++++++++++ .../identity-assignment-body-writes.ts | 117 ++++++++++ ...ystem-identity-assignment-write-refused.ts | 56 +++++ packages/spec/src/ui/action.zod.ts | 38 ++++ 6 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 .changeset/25653-hook-action-body-identity-assignment-write-refused.md create mode 100644 packages/spec/src/identity/identity-assignment-body-writes.test.ts create mode 100644 packages/spec/src/identity/identity-assignment-body-writes.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.hook-action-body-system-identity-assignment-write-refused.ts diff --git a/.changeset/25653-hook-action-body-identity-assignment-write-refused.md b/.changeset/25653-hook-action-body-identity-assignment-write-refused.md new file mode 100644 index 00000000000..65ba18ca08f --- /dev/null +++ b/.changeset/25653-hook-action-body-identity-assignment-write-refused.md @@ -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('')` 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 + + + +**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`. diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index d6e91053d76..cca44378356 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -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. @@ -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('')`, 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. @@ -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 @@ -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.', ), @@ -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 diff --git a/packages/spec/src/identity/identity-assignment-body-writes.test.ts b/packages/spec/src/identity/identity-assignment-body-writes.test.ts new file mode 100644 index 00000000000..882a17932e1 --- /dev/null +++ b/packages/spec/src/identity/identity-assignment-body-writes.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25653] The save-time half of the identity-assignment refusal: a hook that + * declares `runAs: 'system'`, and a `script` action (whose body always runs as + * system), are refused at parse when their sandboxed JS body names a write to + * `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or + * `sys_member`. The run-time half (plugin-security's + * `flow-system-identity-write-refusal.ts`) refuses the write itself. + * + * Controls pin what the run-time refusal leaves legal too: reads of the four + * objects, writes to other objects, a hook under `runAs: 'user'` (the D12 gate + * judges it), an expression body and a code handler. + */ + +import { describe, expect, it } from 'vitest'; +import { HookSchema } from '../data/hook.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ArtifactStagePackageBodySchema, defineStack } from '../stack.zod'; +import { ActionSchema } from '../ui/action.zod'; +import { + IDENTITY_ASSIGNMENT_WRITE_OBJECTS, + identityAssignmentWritesNamedIn, +} from './identity-assignment-body-writes'; + +const ENTRY_ID = 'hook-action-body-system-identity-assignment-write-refused'; +const FOUR = [...IDENTITY_ASSIGNMENT_WRITE_OBJECTS]; +const WRITE_METHODS = ['insert', 'update', 'delete', 'updateMany', 'deleteMany', 'upsert'] as const; +const ASSIGNMENT = new Set(['sys_user_position', 'sys_user_permission_set']); + +const writeSource = (object: string, method: string = 'insert') => + `await ctx.api.object('${object}').${method}({ user_id: ctx.input.owner });`; +const jsBody = (source: string) => ({ language: 'js', source, capabilities: ['api.read', 'api.write'] }); + +const hook = (source: string, extra: Record = {}) => ({ + name: 'grant_on_insert', + object: 'crm_account', + events: ['afterInsert'], + runAs: 'system', + body: jsBody(source), + ...extra, +}); + +const action = (source: string, extra: Record = {}) => ({ + name: 'grant_access', + label: 'Grant access', + type: 'script', + body: jsBody(source), + ...extra, +}); + +interface IssueSig { code: string; path: string; message: string } + +function issuesOf(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } } }, input: unknown): IssueSig[] { + const r = schema.safeParse(input); + expect(r.success, 'expected the parse to refuse this body').toBe(false); + return (r.error?.issues ?? []).map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); +} + +function expectAccepted(schema: { safeParse(v: unknown): { success: boolean; error?: unknown } }, input: unknown): void { + const r = schema.safeParse(input); + expect(r.success, JSON.stringify((r.error as { issues?: unknown })?.issues ?? [])).toBe(true); +} + +describe('identityAssignmentWritesNamedIn — what a body source is judged to write', () => { + it.each(FOUR)('names `%s` for every write method on the literal handle, in every quote form', (object) => { + for (const method of WRITE_METHODS) { + for (const quote of [`'`, `"`, '`']) { + const source = `await ctx.api.object(${quote}${object}${quote}).${method}({ id: 'x' });`; + expect(identityAssignmentWritesNamedIn(source), `${method} ${quote}`).toEqual([object]); + } + } + }); + + it('names a write called on a name the body bound the handle to', () => { + const source = "const grants = ctx.api.object('sys_user_permission_set');\nawait grants.insert({ user_id: ctx.input.owner });"; + expect(identityAssignmentWritesNamedIn(source)).toEqual(['sys_user_permission_set']); + }); + + it('names each object once, in first-seen order', () => { + const source = [writeSource('sys_member'), writeSource('sys_invitation', 'update'), writeSource('sys_member', 'delete')].join('\n'); + expect(identityAssignmentWritesNamedIn(source)).toEqual(['sys_member', 'sys_invitation']); + }); + + it('CONTROL: names nothing for reads of the four objects, or writes to any other object', () => { + for (const object of FOUR) { + for (const method of ['find', 'findOne', 'count', 'aggregate']) { + expect(identityAssignmentWritesNamedIn(`await ctx.api.object('${object}').${method}({});`)).toEqual([]); + } + const bound = `const rows = ctx.api.object('${object}');\nconst held = await rows.find({});`; + expect(identityAssignmentWritesNamedIn(bound)).toEqual([]); + } + for (const object of ['crm_account', 'sys_user', 'sys_member_note', 'sys_user_position_history']) { + expect(identityAssignmentWritesNamedIn(writeSource(object))).toEqual([]); + } + }); +}); + +describe('HookSchema — a runAs: \'system\' hook whose body writes an identity-assignment object is refused', () => { + it.each(FOUR)('refuses a write to `%s` at `body.source`, with its prescription', (object) => { + const issues = issuesOf(HookSchema, hook(writeSource(object))); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'body.source' }]); + const [{ message }] = issues; + expect(message.startsWith(`\`body.source\` writes '${object}' and this hook runs as system (runAs: 'system')`)).toBe(true); + expect(message).toContain(ASSIGNMENT.has(object) ? "Set the hook's runAs to 'user'" : 'organization endpoints'); + }); + + it('one issue per object the body writes', () => { + const issues = issuesOf(HookSchema, hook([writeSource('sys_user_position'), writeSource('sys_member', 'upsert')].join('\n'))); + expect(issues.map((i) => ({ path: i.path, object: /^`body\.source` writes '(\w+)'/.exec(i.message)?.[1] }))).toEqual([ + { path: 'body.source', object: 'sys_user_position' }, + { path: 'body.source', object: 'sys_member' }, + ]); + }); + + it('the registered `hook` type schema — what the metadata save door validates against — refuses it too', () => { + const schema = getMetadataTypeSchema('hook') as unknown as typeof HookSchema; + expect(issuesOf(schema, hook(writeSource('sys_member'))).map((i) => i.path)).toEqual(['body.source']); + }); + + it("CONTROL: under runAs: 'user' the same body parses — the delegated-administration gate judges the user", () => { + expectAccepted(HookSchema, hook(writeSource('sys_user_position'), { runAs: 'user' })); + }); + + it('CONTROL: a system hook that reads the four objects, or writes another object, parses', () => { + expectAccepted(HookSchema, hook("const held = await ctx.api.object('sys_user_position').find({});")); + expectAccepted(HookSchema, hook(writeSource('crm_account', 'update'))); + }); + + it('CONTROL: an expression body and a code handler are not judged — neither is a readable write', () => { + expectAccepted(HookSchema, hook('', { body: { language: 'expression', source: "'sys_member'" } })); + expectAccepted(HookSchema, { ...hook(''), body: undefined, handler: 'grant_on_insert' }); + }); +}); + +describe('ActionSchema — a script action whose body writes an identity-assignment object is refused', () => { + it.each(FOUR)('refuses a write to `%s` at `body.source`, with its prescription', (object) => { + const issues = issuesOf(ActionSchema, action(writeSource(object, 'update'))); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'body.source' }]); + const [{ message }] = issues; + expect(message.startsWith(`\`body.source\` writes '${object}' and an action body runs as system`)).toBe(true); + expect(message).toContain(ASSIGNMENT.has(object) ? "a flow whose runAs is 'user'" : 'organization endpoints'); + }); + + it('the registered `action` type schema refuses it too', () => { + const schema = getMetadataTypeSchema('action') as unknown as typeof ActionSchema; + expect(issuesOf(schema, action(writeSource('sys_invitation'))).map((i) => i.path)).toEqual(['body.source']); + }); + + it('CONTROL: an action body that reads the four objects, or writes another object, parses', () => { + expectAccepted(ActionSchema, action("return ctx.api.object('sys_member').find({});")); + expectAccepted(ActionSchema, action(writeSource('crm_account'))); + }); + + it('CONTROL: an expression body is not judged — it performs no IO', () => { + expectAccepted(ActionSchema, action('', { body: { language: 'expression', source: "'sys_member'" } })); + }); +}); + +describe('every door that parses a stack refuses it', () => { + const stackWith = (extra: Record) => ({ + manifest: { id: 'com.example.grants', name: 'grants', version: '1.0.0', type: 'app', namespace: 'grt' }, + objects: [{ name: 'grt_note', label: 'Note', fields: { title: { type: 'text', label: 'Title' } } }], + ...extra, + }); + + it('defineStack wraps the hook refusal in its ADR-0112 envelope, at `hooks.N.body.source`', () => { + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stackWith({ hooks: [hook(writeSource('crm_account')), hook(writeSource('sys_member'))] }) as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the hook').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'hooks.1.body.source', code: 'custom' }, + ]); + }); + + it('defineStack refuses the action too', () => { + expect(() => defineStack(stackWith({ actions: [action(writeSource('sys_user_permission_set'))] }) as never)).toThrow(); + }); + + it("an artifact's parse refuses the hook: the JSON-stage hook keeps the check through `safeExtend`", () => { + const body = { id: 'com.example.grants', name: 'grants', version: '1.0.0', type: 'app' }; + const refused = ArtifactStagePackageBodySchema.safeParse({ ...body, hooks: [hook(writeSource('sys_member'))] }); + expect(refused.success).toBe(false); + expect(refused.success ? [] : refused.error.issues.map((i) => i.path.join('.'))).toEqual(['hooks.0.body.source']); + }); +}); + +describe('the ADR-0087 ledger', () => { + it('registers one D3 entry at protocol 18, with no D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID); + expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.conversionIds ?? []).toEqual([]); + expect(entry!.replacement).toContain('organization endpoints'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('registers no tombstone: `runAs` and `body` stay in the walked shape', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('data/Hook:runAs') || k.startsWith('data/Hook:body') || k.startsWith('ui/Action:body'))).toEqual([]); + // CONTROL: the flattened table is the real one — it carries a known step-18 tombstone. + expect(all).toContain('api/RestApiEndpoint:timeout'); + }); +}); diff --git a/packages/spec/src/identity/identity-assignment-body-writes.ts b/packages/spec/src/identity/identity-assignment-body-writes.ts new file mode 100644 index 00000000000..b126506e753 --- /dev/null +++ b/packages/spec/src/identity/identity-assignment-body-writes.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * App-authored automation does not use elevation to write the four + * identity-assignment objects (#25251, ruling 6354966423; #25653, the same + * principle for the whole family, ruling record 6384657105 letter A). + * + * The run-time half lives in `@objectstack/plugin-security` + * (`flow-system-identity-write-refusal.ts`): the data middleware refuses, + * ahead of its `isSystem` bypass, a governed write to these four objects from + * a system flow run, a hook that runs as system, or an action body, with + * `PERMISSION_DENIED`. This module is the save-time half: the authoring + * schemas (`HookSchema`, `ActionSchema`) refuse a sandboxed body that runs + * elevated AND names such a write in its source, so the author meets the + * refusal at `os validate`, `defineStack` and the metadata save door instead + * of in a failed run. + * + * It imports nothing, on purpose: `data/hook.zod.ts` and `ui/action.zod.ts` + * both read it, and a leaf keeps either schema's import graph unchanged (the + * `kernel/stored-metadata-body-objects.ts` precedent). + */ + +/** + * The identity-assignment objects app-authored automation's system elevation + * may not write: position holdings, permission-set grants, invitations and + * memberships. Exactly the run-time refusal's set + * (`FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS`, plugin-security). + */ +export const IDENTITY_ASSIGNMENT_WRITE_OBJECTS: ReadonlySet = new Set([ + 'sys_user_position', + 'sys_user_permission_set', + 'sys_invitation', + 'sys_member', +]); + +/** + * The two whose door is the ADR-0090 D12 delegated-administration gate. The + * other two are better-auth's tables, whose door is the organization endpoints. + */ +const ASSIGNMENT_OBJECTS: ReadonlySet = new Set(['sys_user_position', 'sys_user_permission_set']); + +/** + * The write methods a sandboxed body's `ctx.api.object(name)` handle carries + * (`packages/runtime/src/sandbox/quickjs-runner.ts`, the `api.write` set). + * Reads (`find`, `findOne`, `count`, `aggregate`) are not judged: the run-time + * refusal leaves reads of the four objects to every context. + */ +const BODY_WRITE_METHODS = ['insert', 'update', 'delete', 'updateMany', 'deleteMany', 'upsert'] as const; + +const OBJECT_NAMES = [...IDENTITY_ASSIGNMENT_WRITE_OBJECTS].join('|'); +const WRITE_METHODS = BODY_WRITE_METHODS.join('|'); + +/** `.object('')` — the literal handle, in any of the three quote forms. */ +const HANDLE = String.raw`\.\s*object\s*\(\s*(?['"\x60])(?${OBJECT_NAMES})\k\s*\)`; +/** A write call on whatever precedes it: `.insert(`, `?.update(` and so on. */ +const WRITE_CALL = String.raw`\s*\??\.\s*(${WRITE_METHODS})\s*\(`; + +/** A write called straight on the literal handle: `ctx.api.object('sys_member').insert(…)`. */ +const CHAINED_WRITE = new RegExp(HANDLE + WRITE_CALL, 'g'); +/** The literal handle bound to a name: `const members = ctx.api.object('sys_member')`. */ +const BOUND_HANDLE = new RegExp(String.raw`\b(?:const|let|var)\s+(?[A-Za-z_$][\w$]*)\s*=\s*[\w$.]*` + HANDLE, 'g'); + +function escapeIdentifier(name: string): string { + return name.replace(/\$/g, '\\$'); +} + +/** + * The identity-assignment objects a sandboxed body's source names a write to, + * in first-seen order: a write method called on the literal + * `ctx.api.object('')` handle, or on a `const` / `let` / `var` the body + * bound that handle to. + */ +export function identityAssignmentWritesNamedIn(source: string): string[] { + const named = new Set(); + for (const match of source.matchAll(CHAINED_WRITE)) named.add(match.groups!.object); + for (const match of source.matchAll(BOUND_HANDLE)) { + const { binding, object } = match.groups!; + const write = new RegExp(String.raw`(?> = { + hook: "this hook runs as system (runAs: 'system')", + action: 'an action body runs as system', +}; + +const ASSIGNMENT_PRESCRIPTION: Readonly> = { + 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 refusal for one named write: what the body does, why elevation does not + * lift it, and where the change goes instead. + */ +export function identityAssignmentBodyWriteRefusal(object: string, source: IdentityAssignmentBodySource): string { + return ( + `\`body.source\` writes '${object}' and ${SUBJECT[source]}: position holdings, permission-set grants, ` + + 'invitations and memberships are governed identity writes, and system elevation does not lift that, so ' + + 'the write is refused when the body runs. ' + + (ASSIGNMENT_OBJECTS.has(object) ? ASSIGNMENT_PRESCRIPTION[source] : MEMBERSHIP_PRESCRIPTION) + ); +} diff --git a/packages/spec/src/migrations/entries/semantic/18.hook-action-body-system-identity-assignment-write-refused.ts b/packages/spec/src/migrations/entries/semantic/18.hook-action-body-system-identity-assignment-write-refused.ts new file mode 100644 index 00000000000..df1d90f17fa --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.hook-action-body-system-identity-assignment-write-refused.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #25653, ruling record 6384657105 letter A — the D3 entry for the save-time +// half of the identity-assignment refusal, beside +// `18.flow-run-as-system-identity-assignment-write-refused`: `HookSchema` +// refuses a `runAs: 'system'` hook, and `ActionSchema` a `script` action, +// whose sandboxed JS body names a write to one of the four objects. It narrows +// the accept set of both schemas; no key is removed, so there is no tombstone, +// and no D2 conversion either: the move is an authoring choice no rewrite can +// make for the author. +export const entry: SemanticMigration = { + id: 'hook-action-body-system-identity-assignment-write-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'a hook declaring runAs system, or an action of type script, whose sandboxed js body source calls ' + + 'insert, update, delete, updateMany, deleteMany or upsert on ctx.api.object of sys_user_position, ' + + 'sys_user_permission_set, sys_invitation or sys_member', + replacement: + 'For position holdings and permission-set grants, set the hook\'s `runAs` to `\'user\'`, or, for an ' + + 'action, make the grant from a flow whose `runAs` is `\'user\'` (an action of `type: \'flow\'` runs it ' + + 'as the invoking user): the ADR-0090 D12 delegated-administration gate then judges that user, admits ' + + 'a tenant-level or delegated administrator\'s grant, and records that user as the grant\'s ' + + '`granted_by`. For invitations and memberships, call 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.', + reason: + 'App-authored automation does not use elevation to write the four identity-assignment objects. The ' + + 'data middleware already refuses, with `PERMISSION_DENIED` (403), a governed write to ' + + '`sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a hook that ' + + 'runs as system and from an action body, which always runs as system, so such a body\'s write never ' + + 'landed; but every authoring door still accepted the body, 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 `ActionSchema` a `type: \'script\'` action, whose sandboxed JS (`language: ' + + '\'js\'`) `body.source` calls `insert`, `update`, `delete`, `updateMany`, `deleteMany` or `upsert` ' + + 'on `ctx.api.object(...)` named with one of the four as a literal, on the handle or on a name the ' + + 'body bound it to, with one issue per object at `body.source`, at `objectstack validate`, at ' + + '`defineStack` and at the metadata save door (a 422). Unchanged: reads of the four objects, writes ' + + 'to every other object, a hook under `runAs: \'user\'` or `\'inherit\'`, an expression (L1) body, ' + + 'which performs no IO, and a code `handler`. The run-time refusal stays the enforcement for every ' + + 'elevated write. ⛔ NOT convertible: flipping a hook\'s `runAs` changes what every other write of ' + + 'the hook may do, and moving a grant to a flow or an invitation or membership change to an ' + + 'endpoint is a new call, not a rewrite of the body. A census at the change found no hook or action ' + + 'body in this repository that writes the four objects.', + acceptanceCriteria: + '`objectstack validate` reports no issue at `body.source` of a hook or an action: no hook that ' + + 'declares `runAs: \'system\'` and no `type: \'script\'` action has a sandboxed JS body that writes ' + + '`sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member`. Each formerly ' + + 'affected hook either runs as `\'user\'` and, fired by an administrator\'s write, records the grant ' + + 'under that user\'s `granted_by`, or no longer writes the row; each formerly affected action hands ' + + 'the grant to a `runAs: \'user\'` flow or the change to the organization endpoint. Saving each such ' + + 'hook or action through the metadata API succeeds instead of answering a 422 that names ' + + '`body.source`.', +}; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 7545ef0c7f9..e40e057a219 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -32,6 +32,12 @@ import { } from './action-copy-template'; import { templateExpressionInput } from '../shared/typed-expression-input'; import { HookBodySchema } from '../data/hook-body.zod'; +// [#25653] Import-free leaf, shared with `HookSchema`: the identity-assignment +// set, the body-source reading and the refusal text. +import { + identityAssignmentBodyWriteRefusal, + identityAssignmentWritesNamedIn, +} from '../identity/identity-assignment-body-writes'; // Imported file-directly (not via the kernel barrel): the module is // deliberately import-free, so this cannot introduce a cycle. import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/public-auth-features'; @@ -1134,6 +1140,15 @@ const actionObject = () => strictObject({ * The sandbox reports the discarded `ctx.record` writes at INVOCATION time as * well, covering the computed keys, aliases and Studio/API-authored bodies a * parse cannot reach. + * + * An action body runs as system, and elevation does not reach identity + * assignment (#25653, ruling record 6384657105 letter A): its writes to + * `sys_user_position`, `sys_user_permission_set`, `sys_invitation` and + * `sys_member` are refused at run time with `PERMISSION_DENIED`, and the + * refinement on {@link ActionSchema} refuses a sandboxed JS body whose + * `source` names such a write. A grant goes through a flow whose `runAs` is + * `'user'` (an action of `type: 'flow'` runs it as the invoking user); + * invitations and memberships through the organization endpoints. */ body: HookBodySchema.optional().describe('Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`.'), @@ -1956,6 +1971,28 @@ function refuseUnopenableOnSuccessNavigate( if (refusal !== undefined) ctx.addIssue({ code: 'custom', path: ['navigate'], message: refusal }); } +/** + * [#25653, ruling record 6384657105 letter A] App-authored automation does not + * use elevation to write the four identity-assignment objects, and an action + * body always runs as system (`buildActionExecutionContext`, runtime). The data + * middleware refuses the write at run time (`flow-system-identity-write-refusal.ts`, + * plugin-security); this refuses the `script` action whose sandboxed JS (L2) + * `body` names one — a write method on `ctx.api.object('')`, called on + * the handle or on a name the body bound it to — at every door that parses an + * action, one issue per object at `body.source`. An expression (L1) body + * performs no IO, and reads of the four objects stay legal, as at run time. + * `HookSchema` carries the same check for a `runAs: 'system'` hook. + */ +function refuseIdentityAssignmentBodyWrite( + data: { type?: string; body?: { language?: string; source?: unknown } }, + ctx: z.core.$RefinementCtx, +): void { + if (data.type !== 'script' || data.body?.language !== 'js' || typeof data.body.source !== 'string') return; + for (const object of identityAssignmentWritesNamedIn(data.body.source)) { + ctx.addIssue({ code: 'custom', path: ['body', 'source'], message: identityAssignmentBodyWriteRefusal(object, 'action') }); + } +} + export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // Require `target` for types that reference an external resource if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { @@ -2202,6 +2239,7 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { path: ['undoable'], }).superRefine(refuseDeclarativeUpdateContradictions(ACTION_DECLARATIVE_UPDATE)) .superRefine(refuseInertOutcomeMessages) + .superRefine(refuseIdentityAssignmentBodyWrite) // Grade gate first, feature gate last: `requiresFeature` composes onto // whatever `visible` it finds, so this order keeps `features.*` the final // term — the convention the platform-objects feature-gate guard reads. One From 2a0debc6e29c540c31efed23a25b147865f95d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:56:39 +0000 Subject: [PATCH 2/2] docs(spec): regenerate the hook reference for the runAs identity-assignment sentence Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzcHS6dL9h6WLdsGCH --- content/docs/references/data/hook.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 7c712afa1d5..2932817ebe3 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -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). |