diff --git a/.changeset/15204-s2c-catalog-activation-door.md b/.changeset/15204-s2c-catalog-activation-door.md new file mode 100644 index 00000000000..44ec6e90638 --- /dev/null +++ b/.changeset/15204-s2c-catalog-activation-door.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': minor +--- + +feat(runtime): `POST /api/v1/security/_activation/:type/:name` switches a position or a permission set on or off for the deployment (ADR-0126 §3 regime C, as ADR-0131 D6 amends it) + +Clause-②: yes + +The authorization resolver reads whether a position or a permission set is switched off from the activation ledger, `sys_metadata_activation` (types `position` and `permission`), and never from the catalog row's `active` column (ADR-0131 D3). Until now nothing wrote that ledger for these two types, so a deactivation switched nothing off. This route is the write half: + +- **Route:** `POST /api/v1/security/_activation/:type/:name`, with `:type` either `position` or `permission`. It is also mounted under `/api/v1/environments/:environmentId` when project scoping is on, like the action door. +- **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses. +- **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written. +- **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written. +- **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook. + +The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index cb4d1bca4ab..dd887597acc 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -140,6 +140,8 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | | `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. | +A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable. + The switches are operator-only where one install serves several organizations. The refusal names the same path whichever layer answers it: `403 NOT_OVERRIDABLE` for a write that names no base, and `403 ITEM_LOCKED` for one that names the read-only package. Which writes are refused, and with which code, is unchanged. An environment row stored over a managed item of a type sealed against overlays (a flow, action, hook, object or datasource, for example — any type whose registry entry admits no overlay) before the seal — written through the `OS_METADATA_WRITABLE` hatch — is **not served** (ADR-0131 D6): every read, and boot, takes the package's definition, and the row stays at rest, untouched. Boot names each such row, per type (`[metadata_sealed_overlay_unserved]`), with the two remedies: re-express the change as a new item under a new name, or delete the stored row. A stored fork of a code-declared permission set is the one exception: it keeps the detection reading and the operator's Discard Overlay action it already had. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 06ddf0b61e8..af85e95b651 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -409,6 +409,17 @@ A catalogue row's own `active` column (`sys_permission_set.active`, Deactivate action writes today, so a Deactivate in Setup does not switch the grant off. +The ledger row is written through +`POST /api/v1/security/_activation/:type/:name`, with `:type` either +`position` or `permission` and the body `{ enabled }` (`false` switches the +grant off, `true` back on). The caller needs `manage_metadata`; under a `group` +or `isolated` tenancy posture the caller must also be the platform operator, +because the row reaches every organization (ADR-0126 §5). The name must resolve +in the security catalog, or the route answers `404`. It is the same authority +as the flow and action activation switches. The two audience anchors, +`everyone` and `guest`, are refused with `400` in either direction: one row +would switch the authenticated or anonymous baseline off for every principal. + - A deactivated **permission set** contributes nothing: not its name, not its `system_permissions`, not its `tab_permissions`. As with an expired grant, a deactivated unscoped `admin_full_access` no longer derives `platform_admin` diff --git a/packages/core/src/security/row-active.ts b/packages/core/src/security/row-active.ts index 23a8a33e7a6..2f46c79e7af 100644 --- a/packages/core/src/security/row-active.ts +++ b/packages/core/src/security/row-active.ts @@ -12,11 +12,20 @@ * > Deactivate this position? Users keep their assignment but the position stops * > granting permissions until re-activated. * - * Nothing read the column, so the promise was false: deactivation changed a - * badge in Setup and the assignments kept granting. Correctness lives at - * RESOLUTION time — the same placement ADR-0091 chose for validity windows, - * and for the same reason: a catalogue flag that is only honoured by a cleanup - * job is an unenforced security property. + * ⚠️ The catalogue row's `active` column DECIDES NO GRANT any more (ADR-0131 + * D3, ADR-0126 §4). `resolveUserAuthzGrants` reads deactivation for both types + * from the deployment-level activation ledger, `sys_metadata_activation` + * (`metadata_type` `position` / `permission`), written through + * `POST /api/v1/security/_activation/:type/:name`; it never calls this + * predicate. The Deactivate dialog's promise is kept by that ledger row, not by + * this column. + * + * What still reads the column through this predicate, on `main`: the explain + * engine's "deactivated" label (`plugin-security` `explain-engine.ts`) and the + * security plugin's set loader (`plugin-security` `security-plugin.ts`). The + * last-administrator guard and the sharing-rule position check read the ledger + * instead. The reasoning below is about reading the column at all, and holds + * for each remaining reader. * * ## Why the predicate is "explicitly deactivated", not "explicitly active" * @@ -57,9 +66,9 @@ export interface ActivatableRow { * True unless the row carries an `active` column that is explicitly OFF. * * The ONE predicate every reader of `sys_permission_set.active` / - * `sys_position.active` uses, so the resolver that enforces the flag and the - * break-glass guard that simulates a write to it can never disagree about what - * "deactivated" means. + * `sys_position.active` uses (the readers are listed in the module doc), so + * they can never disagree about what a deactivated ROW means. It is not the + * grant switch: the resolver reads the activation ledger instead. */ export function isRowActive(row: ActivatableRow | null | undefined): boolean { if (!row) return false; diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 3b28d7410bd..0e669b08f6b 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (82 rows -// / 18 families) and `packages/runtime/src/route-ledger.ts` (84 rows / 22 +// / 18 families) and `packages/runtime/src/route-ledger.ts` (85 rows / 22 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 0e7242d49a9..10dd2fc9ea8 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 84 rows over 22 domains. Its +// `packages/runtime/src/route-ledger.ts`: 85 rows over 22 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -404,11 +404,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // rather than added to the baseline. Re-derived by the companion test; the // controls moved with the rows ("route: '" and "domain: '" 82 -> 84, // RouteLedgerEntry unchanged at 2). - population: 84, - reachable: 84, + // [ADR-0131 D6] 84 -> 85: the `POST /security/_activation/:type/:name` row + // arrived with its mount (the catalog activation door). It carries + // `domain: '/security'`, an EXISTING key, so `reachable` moves with + // `population`, `blindSpot` stays 0 and `keys` stays 22. Its module, + // `domains/catalog-activation.ts`, declares no DomainRoute prefix — the + // `/security` domain dispatches to it — so the domain-file counts in the + // note below are unchanged. Controls moved with the row ("route: '" and + // "domain: '" 84 -> 85, RouteLedgerEntry unchanged at 2). + population: 85, + reachable: 85, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 84, "domain: '": 84, RouteLedgerEntry: 2 }, + controls: { "route: '": 85, "domain: '": 85, RouteLedgerEntry: 2 }, // [#22432] Re-derived on the merged ref: the note's two counts had stood at // 11 files / 5 classified / 16 baselined since before the `/analytics` // domain was classified (#21061), which named one more file and moved one diff --git a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts new file mode 100644 index 00000000000..4dbe17939fc --- /dev/null +++ b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] The security catalog's +// activation door on a REAL booted showcase: +// `POST /api/v1/security/_activation/:type/:name`, body `{ enabled?: boolean }`. +// +// What only a boot can measure, and so what this file is for: +// +// - the door is MOUNTED on the wire and reachable — the rest of `/security` +// is the REST server's, and a dispatcher arm nothing routes to is the +// defect class the live-mount parity gate exists for; +// - the name is checked against the catalog the security plugin really binds +// (a declared position, a declared permission set, a name nobody declares); +// - the write goes through the engine, so the last-admin guard's ledger hook +// really judges switching `admin_full_access` off: refused when no +// administrator would be left, landing when one stands; +// - the resolver over the booted engine stops granting through a position +// the door switched off, for a holder of that position. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const LEDGER = 'sys_metadata_activation'; +/** Infrastructure rows, not tenant data — the store's own posture. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** A showcase position (`src/security/positions.ts`) and the set it names. */ +const POSITION = 'auditor'; +const POSITION_SET = 'showcase_auditor'; +/** A second showcase position, held by this file's own holder for the resolver leg. */ +const HELD_POSITION = 'exec'; +const HELD_POSITION_SET = 'showcase_executive'; +const HOLDER_EMAIL = 'exec.holder@example.com'; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; + update(object: string, data: Record, options?: unknown): Promise; +} + +describe('the catalog activation door, on a booted showcase', () => { + let stack: VerifyStack; + let token: string; + let ql: Engine; + + const flip = (type: string, name: string, body: unknown) => + stack.apiAs(token, 'POST', `/security/_activation/${type}/${name}`, body); + const rowsOf = (type: string, name: string) => + ql.find(LEDGER, { where: { metadata_type: type, name }, context: SYSTEM_CTX }); + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('a declared position switches off: 200, one deployment-level row', async () => { + const res = await flip('position', POSITION, { enabled: false }); + const text = await res.clone().text(); + + expect(res.status, `answered ${res.status}: ${text}`).toBe(200); + expect(JSON.parse(text).data).toEqual({ type: 'position', name: POSITION, enabled: false }); + const rows = await rowsOf('position', POSITION); + expect(rows).toHaveLength(1); + // SQLite/libsql round-trip booleans as 0/1 — either spelling is "off". + expect(rows[0].active === false || rows[0].active === 0).toBe(true); + }); + + it('the resolver over the booted engine stops granting through a switched-off position, and grants again once it is back on', async () => { + // Its own holder, so the leg depends on nothing a boot seeds later: a + // signed-up member, assigned the `exec` position unscoped. + await stack.signUp(HOLDER_EMAIL); + const [user] = await ql.find('sys_user', { where: { email: HOLDER_EMAIL }, limit: 1, context: SYSTEM_CTX }); + expect(user?.id, `no sys_user row for ${HOLDER_EMAIL}`).toBeTruthy(); + const holder = String(user.id); + await ql.insert( + 'sys_user_position', + { id: 'usp_catalog_activation_exec', user_id: holder, position: HELD_POSITION }, + { context: SYSTEM_CTX }, + ); + + // CONTROL: before the flip the position grants its set — without it the + // two assertions below would pass on a holder that was never granted. + const before = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(before.positions).toContain(HELD_POSITION); + expect(before.permissions).toContain(HELD_POSITION_SET); + + const offRes = await flip('position', HELD_POSITION, { enabled: false }); + expect(offRes.status, await offRes.clone().text()).toBe(200); + const off = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(off.positions).not.toContain(HELD_POSITION); + expect(off.permissions).not.toContain(HELD_POSITION_SET); + + const onRes = await flip('position', HELD_POSITION, { enabled: true }); + expect(onRes.status, await onRes.clone().text()).toBe(200); + expect(await rowsOf('position', HELD_POSITION)).toHaveLength(1); + const on = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(on.positions).toContain(HELD_POSITION); + expect(on.permissions).toContain(HELD_POSITION_SET); + }); + + it('a declared permission set switches off and back on', async () => { + const off = await flip('permission', POSITION_SET, { enabled: false }); + expect(off.status, await off.clone().text()).toBe(200); + const on = await flip('permission', POSITION_SET, { enabled: true }); + expect(on.status, await on.clone().text()).toBe(200); + const rows = await rowsOf('permission', POSITION_SET); + expect(rows).toHaveLength(1); + expect(rows[0].active === true || rows[0].active === 1).toBe(true); + }); + + // [ADR-0135 D5.2] The last-administrator guard refuses a ledger switch-off + // of `admin_full_access` only when it would leave nobody who can sign in as + // an administrator. Standing here is the seeded admin's unscoped + // `admin_full_access` grant (the `single` posture declares no owner email) + // plus the default organization's owner membership, which the same admin + // holds. So the pair: with the owner membership demoted the grant is the + // last standing and the switch-off is refused; with it standing the + // switch-off lands. Every change is undone in `finally`, so a failed + // assertion leaves the shared boot as it found it. + it('switching `admin_full_access` off with no administrator left standing is refused 403, and no row lands', async () => { + const owners = await ql.find('sys_member', { where: { role: 'owner' }, context: SYSTEM_CTX }); + // Anti-vacuity: the boot really has an owner to demote. + expect(owners.length, 'no owner membership on this boot').toBeGreaterThan(0); + try { + for (const m of owners) await ql.update('sys_member', { id: m.id, role: 'member' }, { context: SYSTEM_CTX }); + + const res = await flip('permission', 'admin_full_access', { enabled: false }); + const body = await res.json() as { error?: { code?: string } }; + + expect(res.status).toBe(403); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(await rowsOf('permission', 'admin_full_access')).toEqual([]); + } finally { + for (const m of owners) await ql.update('sys_member', { id: m.id, role: 'owner' }, { context: SYSTEM_CTX }); + } + }); + + it('CONTROL: switching `admin_full_access` off with the organization owner still standing lands 200', async () => { + try { + const res = await flip('permission', 'admin_full_access', { enabled: false }); + const text = await res.clone().text(); + + expect(res.status, text).toBe(200); + const rows = await rowsOf('permission', 'admin_full_access'); + expect(rows).toHaveLength(1); + expect(rows[0].active === false || rows[0].active === 0).toBe(true); + } finally { + // Restored through the engine, not the door: with the set off the + // caller may no longer hold `manage_metadata`. Switching it back on + // is never refused by the guard. + for (const row of await rowsOf('permission', 'admin_full_access')) { + await ql.update(LEDGER, { id: row.id, active: true }, { context: SYSTEM_CTX }); + } + } + const restored = await rowsOf('permission', 'admin_full_access'); + expect(restored.every((r) => r.active === true || r.active === 1)).toBe(true); + }); + + it('a name nobody declares is 404 and writes nothing', async () => { + const res = await flip('position', 'no_such_position', { enabled: false }); + + expect(res.status).toBe(404); + expect(await rowsOf('position', 'no_such_position')).toEqual([]); + }); + + it('a type with no switch is refused 400 by the door itself — the route is the door\'s, not a router miss', async () => { + const res = await flip('capability', 'manage_metadata', { enabled: false }); + const text = await res.text(); + + expect(res.status, text).toBe(400); + expect(text).toContain('/security/_activation/:type/:name'); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index de7fd6750f5..9826582fe3f 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1768,20 +1768,51 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); }; + // ── Security catalog activation (ADR-0126 §3 regime C, ADR-0131 D3/D6) ── + // `POST /security/_activation/:type/:name` — switch a position or a + // permission set on/off for this deployment + // (`domains/catalog-activation.ts`). The rest of `/security` is the + // REST server's on the wire; this one door is the dispatcher's, + // because the activation gate and the two sibling activation doors + // live here. Dispatched through `dispatcher.dispatch()` like the + // action door, so the session resolves into `executionContext` and + // the per-project kernel is swapped in before the gates read either. + // The literal `_activation` segment collides with no REST-mounted + // `/security` route (machine names cannot begin with `_`). + const registerSecurityActivationRoutes = (base: string) => { + server!.post(`${base}/security/_activation/:type/:name`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch( + 'POST', + `/security/_activation/${req.params.type}/${req.params.name}`, + req.body, + req.query, + { request: req }, + ); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + }; + const enableProjectScoping = config.scoping?.enableProjectScoping ?? false; const projectResolution = config.scoping?.projectResolution ?? 'auto'; if (enableProjectScoping && projectResolution === 'required') { registerAutomationRoutes(`${prefix}/environments/:environmentId`); registerActionRoutes(`${prefix}/environments/:environmentId`); + registerSecurityActivationRoutes(`${prefix}/environments/:environmentId`); registerAIRoutes(`${prefix}/environments/:environmentId`); } else { registerAutomationRoutes(prefix); registerActionRoutes(prefix); + registerSecurityActivationRoutes(prefix); registerAIRoutes(prefix); if (enableProjectScoping) { registerAutomationRoutes(`${prefix}/environments/:environmentId`); registerActionRoutes(`${prefix}/environments/:environmentId`); + registerSecurityActivationRoutes(`${prefix}/environments/:environmentId`); registerAIRoutes(`${prefix}/environments/:environmentId`); } } diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index 199c52bc14e..4df8754ee03 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -68,12 +68,13 @@ import { import { isNativeErrorName, INTERNAL_ERROR_MESSAGE, sandboxBusinessMessage } from '@objectstack/types'; import * as actionExec from '../action-execution.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; -import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; +import { validationFailureDetails } from '../validation-failure.js'; // [ADR-0126 §5] The shared activation write-authority gates — the same two // tiers `/automation`'s toggle door passes, one implementation. import { refuseUngrantedActivationWrite, refuseUngrantedActivationAuthoring, + readActivationBody, ACTION_ACTIVATION_SUBJECT, } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -178,43 +179,12 @@ async function handleActionActivationWrite( const actionName = parts[2]; // ── 2. body ───────────────────────────────────────────────────────────── - // Built through the shared `validationFailure` constructor and its own - // `details` reader, so this door's 400s carry the same `VALIDATION_FAILED` - // envelope and `fields[]` shape as every other one — ⛔ never a hand-rolled - // details literal that agrees with them by eye (#3878/#3899). - const invalidBody = ( - message: string, - fields: Array<{ field: string; code: string; message: string }>, - ): HttpDispatcherResult => ({ - handled: true, - response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, fields))), - }); - - const toggleBody = body ?? {}; - if (typeof toggleBody !== 'object' || Array.isArray(toggleBody)) { - return invalidBody('Invalid activation body — expected { enabled?: boolean }', [ - { field: '(body)', code: 'invalid_type', message: 'expected an object' }, - ]); - } - const unknownKeys = Object.keys(toggleBody).filter((k) => k !== 'enabled'); - if (unknownKeys.length > 0) { - return invalidBody( - `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the activation body is { enabled?: boolean }`, - // `unknown_field` — the ADR-0114 catalog member for "a key the - // target does not declare". - unknownKeys.map((k) => ({ - field: k, - code: 'unknown_field', - message: 'not an activation field — did you mean `enabled`?', - })), - ); - } - if ('enabled' in toggleBody && typeof (toggleBody as Record).enabled !== 'boolean') { - return invalidBody('`enabled` must be a boolean (JSON true/false, not a string)', [ - { field: 'enabled', code: 'invalid_type', message: 'expected a boolean' }, - ]); - } - const enabled = (toggleBody as { enabled?: boolean }).enabled ?? true; + // The one activation body reader every `_activation` door shares, so this + // door's 400s carry the same `VALIDATION_FAILED` envelope and `fields[]` + // shape as every other one (#3878/#3899). + const bodyReading = readActivationBody(deps, body); + if (bodyReading.refusal) return bodyReading.refusal; + const enabled = bodyReading.enabled; // ── 3. declaration ────────────────────────────────────────────────────── const declaration = await actionExec.resolveRouteActionDeclaration(deps, context, { diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 6c8c6d061d3..0e2e2a4473a 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -86,6 +86,7 @@ import { effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstac import { postureEnforcesWall } from '@objectstack/spec/security'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import { validationFailure, validationFailureDetails, VALIDATION_FAILED_STATUS } from '../validation-failure.js'; /** [ADR-0126 §5] Refusal vocabulary for the activation (enable/disable) gates. */ export const ACTIVATION_DENY_STATUS = 403; @@ -138,6 +139,86 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { 'alongside it stays open to you.', }; +/** + * [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] The position door's wording. + * + * A position is pre-charted on the regime and has no clone door, so — as for + * actions — the refusal names the operator and the per-organization half that + * stays open: an organization administrator still decides who HOLDS the + * position in their own organization. + */ +export const POSITION_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a position', + remedy: + 'To switch a position off for this installation, ask your platform operator; removing its assignments in your ' + + 'own organization stays open to you.', +}; + +/** + * [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] The permission-set door's + * wording. Regime C charters the clone for permission sets (the "Clone" action + * the packaged-base refusal already names), so the remedy is the flow door's. + */ +export const PERMISSION_SET_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a permission set', + remedy: 'To customize this permission set for your organization, clone it under a new name instead.', +}; + +/** + * The activation body every `_activation` door reads: `{ enabled?: boolean }`, + * unknown keys refused, `enabled` defaulting to `true`. + * + * One reader so the doors cannot drift into two body contracts. The flow + * toggle's lesson is why each check is here: on an unchecked body + * `{"enable": false}` — one letter off — ENABLED the artifact and answered 200, + * and the caller trying to switch something OFF is exactly the caller this must + * not silently invert. The 400s go through the shared `validationFailure` + * constructor, so they carry the `VALIDATION_FAILED` envelope and `fields[]` + * shape every other door does. + * + * Returns a refusal to short-circuit on, or the `enabled` value to write. + */ +export function readActivationBody( + deps: DomainHandlerDeps, + body: unknown, +): { refusal: HttpDispatcherResult } | { refusal?: undefined; enabled: boolean } { + const invalidBody = ( + message: string, + fields: Array<{ field: string; code: string; message: string }>, + ): { refusal: HttpDispatcherResult } => ({ + refusal: { + handled: true, + response: deps.error(message, VALIDATION_FAILED_STATUS, validationFailureDetails(validationFailure(message, fields))), + }, + }); + + const toggleBody = body ?? {}; + if (typeof toggleBody !== 'object' || Array.isArray(toggleBody)) { + return invalidBody('Invalid activation body — expected { enabled?: boolean }', [ + { field: '(body)', code: 'invalid_type', message: 'expected an object' }, + ]); + } + const unknownKeys = Object.keys(toggleBody).filter((k) => k !== 'enabled'); + if (unknownKeys.length > 0) { + return invalidBody( + `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the activation body is { enabled?: boolean }`, + // `unknown_field` — the ADR-0114 catalog member for "a key the + // target does not declare". + unknownKeys.map((k) => ({ + field: k, + code: 'unknown_field', + message: 'not an activation field — did you mean `enabled`?', + })), + ); + } + if ('enabled' in toggleBody && typeof (toggleBody as Record).enabled !== 'boolean') { + return invalidBody('`enabled` must be a boolean (JSON true/false, not a string)', [ + { field: 'enabled', code: 'invalid_type', message: 'expected a boolean' }, + ]); + } + return { enabled: (toggleBody as { enabled?: boolean }).enabled ?? true }; +} + /** * The §5 gate itself. * @@ -155,21 +236,24 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { * THROW. See the posture read below for the class that throws and why a caller * must not absorb it into "no gate to enforce". * - * ⚠️ TWO DOORS, one gate body — so every exit above, the throw included, - * reaches BOTH of them and neither is "the" activation door: + * ⚠️ THREE DOORS, one gate body — so every exit above, the throw included, + * reaches ALL of them and none is "the" activation door: * * - `./actions.ts` — `POST /actions/_activation/:object/:action`, calling * this function directly with {@link ACTION_ACTIVATION_SUBJECT}; * - `./automation.ts` — `POST /automation/:name/toggle`, through its * `refuseUngrantedFlowActivationWrite` wrapper and - * {@link FLOW_ACTIVATION_SUBJECT}. + * {@link FLOW_ACTIVATION_SUBJECT}; + * - `./catalog-activation.ts` — `POST /security/_activation/:type/:name` + * for `position` and `permission`, calling this function directly with + * {@link POSITION_ACTIVATION_SUBJECT} / {@link PERMISSION_SET_ACTIVATION_SUBJECT}. * - * Both `await` the call, so the throw exit is a rejected promise the domain + * All `await` the call, so the throw exit is a rejected promise the domain * handler propagates and the dispatcher's error exit renders — nothing is - * unhandled at either door. Written down because a change to this body is a - * change to two routes: a claim about "the gate" that was measured at one door - * is a claim about half the surface, and both doors are pinned together in - * `./tenancy-posture-outage-gates.test.ts` for that reason. + * unhandled at any door. Written down because a change to this body is a + * change to every route above: a claim about "the gate" that was measured at + * one door is a claim about part of the surface, and the doors are pinned + * together in `./tenancy-posture-outage-gates.test.ts` for that reason. */ export async function refuseUngrantedActivationWrite( deps: DomainHandlerDeps, diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts new file mode 100644 index 00000000000..d4d0bbdb5cf --- /dev/null +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -0,0 +1,483 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] The security catalog's +// activation door, `POST /security/_activation/:type/:name`, for `position` and +// `permission`. +// +// What is pinned, and why each half: +// +// - the AUTHORITY is the flow and action doors' — `manage_metadata`, then the +// ADR-0126 §5 posture rule — and every refusal leaves the ledger untouched +// (a gate that refused after the write would be the measured leak, with +// persistence); +// - the CONTRACT: shape, body, the name resolving in the catalog the resolver +// reads, a store failure or a hook refusal never reading as a flip; +// - the EFFECT is measured through `resolveUserAuthzGrants` over the very +// engine the door wrote, not by reading the row back: a row the resolver +// does not honour would be a switch that switches nothing. + +import { describe, it, expect } from 'vitest'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { bindTestSecurityCatalog, type TestSecurityCatalog } from '../security/security-catalog.testkit.js'; + +const LEDGER = 'sys_metadata_activation'; +const USER = 'u_holder'; +const POSITION = 'sales_rep'; +const SET = 'sales_access'; +const DIRECT_SET = 'report_reader'; + +const CATALOG: TestSecurityCatalog = { + positions: [ + { name: POSITION, permissionSets: [SET], _packageId: 'crm' }, + // The audience anchors are DECLARED here so a refusal of them cannot be + // a 404 in disguise: the lookup would find them. + { name: 'everyone', permissionSets: [] }, + { name: 'guest', permissionSets: [] }, + ], + permissions: [ + { name: SET, systemPermissions: ['cap_sales'] }, + { name: DIRECT_SET, systemPermissions: ['cap_reports'] }, + { name: 'admin_full_access', systemPermissions: ['manage_metadata'] }, + ], +}; + +type Row = Record; + +interface EngineOptions { + /** No ledger object registered — a composition without `PlatformObjectsPlugin`. */ + omitLedger?: boolean; + /** Bind no security catalog — an engine no security plugin started on. */ + unbound?: boolean; + /** Throw this from every ledger write — a hook refusal or a store failure. */ + writeError?: Error; +} + +/** A table-backed engine double: the store's keyed find/insert/update and the resolver's `$in` read. */ +function makeEngine(opts: EngineOptions = {}) { + const tables: Record = { + sys_user: [{ id: USER }], + sys_member: [], + sys_user_position: [{ user_id: USER, position: POSITION, organization_id: null }], + sys_user_permission_set: [ + { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: null }, + ], + [LEDGER]: [], + }; + const writes: Array<{ op: 'insert' | 'update'; data: Row }> = []; + const matches = (row: Row, where: Row | undefined): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as Row)) return ((v as Row).$in as unknown[]).includes(row[k]); + if (v === null) return (row[k] ?? null) === null; + return row[k] === v; + }); + let nextId = 1; + const engine: any = { + registry: { + getObject: (name: string) => (name === LEDGER && !opts.omitLedger ? { name: LEDGER } : undefined), + }, + async find(object: string, options: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, options?.where)); + return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + }, + async insert(object: string, data: Row) { + if (opts.writeError) throw opts.writeError; + writes.push({ op: 'insert', data }); + const row = { id: `act_${nextId++}`, ...data }; + (tables[object] ??= []).push(row); + return row; + }, + async update(object: string, data: Row, options?: any) { + // The real engine's dispatch: the store updates by id, and nothing + // looser may pass here (`check:engine-double-contract`). + const dispatch = assertEngineUpdateDispatch(data, options); + if (dispatch.kind !== 'by-id') throw new Error('fake engine: only by-id updates are modelled'); + if (opts.writeError) throw opts.writeError; + writes.push({ op: 'update', data }); + const row = (tables[object] ?? []).find((r) => r.id === dispatch.id); + if (row) Object.assign(row, data); + return row; + }, + }; + if (!opts.unbound) bindTestSecurityCatalog(engine, CATALOG); + return { engine, tables, writes }; +} + +interface Harness { + dispatcher: HttpDispatcher; + engine: any; + tables: Record; + writes: Array<{ op: 'insert' | 'update'; data: Row }>; +} + +function boot(posture: 'single' | 'group' | 'isolated' | null, opts: EngineOptions = {}): Harness { + const { engine, tables, writes } = makeEngine(opts); + const services: Record = { objectql: engine, data: engine }; + if (posture) services.tenancy = { posture }; + const resolve = (name: string): unknown => services[name] ?? null; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel as never), engine, tables, writes }; +} + +/** A tenant org admin holding `manage_metadata` — the §5 gate is then the only thing left. */ +const TENANT_ADMIN = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_northwind_owner', + positions: ['org_owner', 'org_admin'], + permissions: ['organization_admin'], + systemPermissions: ['manage_metadata'], + posture: 'TENANT_ADMIN', + organizationId: 'org_northwind', + }, +} as unknown as HttpProtocolContext); + +const PLAIN_MEMBER = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_member', positions: ['org_member'], systemPermissions: [] }, +} as unknown as HttpProtocolContext); + +const PLATFORM_OPERATOR = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_saas_operator', + positions: ['platform_admin'], + permissions: ['admin_full_access'], + systemPermissions: ['manage_metadata'], + posture: 'PLATFORM_ADMIN', + organizationId: null, + }, +} as unknown as HttpProtocolContext); + +const ANONYMOUS = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { positions: [], systemPermissions: [] }, +} as unknown as HttpProtocolContext); + +const statusOf = (r: any): unknown => r?.response?.status; +const codeOf = (r: any): unknown => r?.response?.body?.error?.code ?? r?.response?.body?.error?.details?.code; +const messageOf = (r: any): string => String(r?.response?.body?.error?.message ?? ''); + +const flip = (h: Harness, ctx: HttpProtocolContext, path: string, body: unknown = { enabled: false }) => + h.dispatcher.handleSecurity(path, 'POST', body, {}, ctx); + +const grantsOf = (h: Harness) => resolveUserAuthzGrants(h.engine, USER, {}); + +describe('the catalog activation door — authority', () => { + for (const [type, name] of [['position', POSITION], ['permission', SET]] as const) { + describe(`\`${type}\``, () => { + it('`single`: a tenant admin holding `manage_metadata` flips it', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/${type}/${name}`); + + expect(statusOf(res)).toBe(200); + expect(res.response?.body?.data).toEqual({ type, name, enabled: false }); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: type, name, active: false }), + ]); + }); + + it('no tenancy service behaves like `single` (ADR-0093 D4/D5)', async () => { + const h = boot(null); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/${type}/${name}`))).toBe(200); + expect(h.writes).toHaveLength(1); + }); + + it('REFUSES a caller without `manage_metadata` 403, and writes nothing', async () => { + const h = boot('single'); + + const res = await flip(h, PLAIN_MEMBER(), `/_activation/${type}/${name}`); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(messageOf(res)).toContain('manage_metadata'); + expect(h.writes).toEqual([]); + }); + + it('REFUSES an anonymous caller at the domain floor 401, and writes nothing', async () => { + const h = boot('single'); + + const res = await flip(h, ANONYMOUS(), `/_activation/${type}/${name}`); + + expect(statusOf(res)).toBe(401); + expect(h.writes).toEqual([]); + }); + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\`: REFUSES a tenant admin 403, naming the posture, and writes nothing`, async () => { + const h = boot(posture); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/${type}/${name}`); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(messageOf(res)).toContain(posture); + expect(messageOf(res)).toContain('ADR-0126 §5'); + // #7450 — nothing about the caller's own positions or sets. + expect(messageOf(res)).not.toContain('org_owner'); + expect(messageOf(res)).not.toContain('organization_admin'); + expect(h.writes).toEqual([]); + }); + + it(`\`${posture}\`: ALLOWS the platform operator`, async () => { + const h = boot(posture); + + expect(statusOf(await flip(h, PLATFORM_OPERATOR(), `/_activation/${type}/${name}`))).toBe(200); + expect(h.writes).toHaveLength(1); + }); + } + }); + } + + it('the refusal names what is being switched, per type', async () => { + const position = messageOf(await flip(boot('group'), TENANT_ADMIN(), `/_activation/position/${POSITION}`)); + const set = messageOf(await flip(boot('group'), TENANT_ADMIN(), `/_activation/permission/${SET}`)); + + expect(position).toContain('a position'); + expect(set).toContain('a permission set'); + }); + + it('a refused caller learns nothing about the shape or the name: refused before both', async () => { + const h = boot('group'); + + const unknownName = await flip(h, TENANT_ADMIN(), '/_activation/position/no_such_position'); + const badShape = await flip(h, TENANT_ADMIN(), '/_activation/capability/x', { enable: false }); + + expect(statusOf(unknownName)).toBe(403); + expect(statusOf(badShape)).toBe(403); + expect(h.writes).toEqual([]); + }); +}); + +describe('the catalog activation door — contract', () => { + it.each([ + ['/_activation/position', 'missing name'], + ['/_activation/position/a/b', 'one segment too many'], + ['/_activation/capability/manage_metadata', 'a type with no switch'], + ['/_activation/permission_set/sales_access', 'a type spelled other than the ledger spells it'], + ])('refuses %s (%s) 400, naming the shape', async (path) => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), path); + + expect(statusOf(res)).toBe(400); + expect(messageOf(res)).toContain('/security/_activation/:type/:name'); + expect(h.writes).toEqual([]); + }); + + for (const anchor of ['everyone', 'guest']) { + for (const enabled of [false, true]) { + it(`refuses the audience anchor \`${anchor}\` with enabled: ${enabled} — 400, nothing written`, async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${anchor}`, { enabled }); + + expect(statusOf(res)).toBe(400); + expect(messageOf(res)).toContain(`'${anchor}' is an audience anchor`); + expect(messageOf(res)).toContain('not switchable through this door'); + expect(h.writes).toEqual([]); + expect(h.tables[LEDGER]).toEqual([]); + }); + } + + it(`the authority gates still answer first for \`${anchor}\` — a walled tenant admin gets 403, not the anchor 400`, async () => { + const h = boot('isolated'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${anchor}`); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(h.writes).toEqual([]); + }); + } + + it('an anchor NAME under `permission` is not an anchor — it is looked up like any set (404 here)', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), '/_activation/permission/everyone'); + + expect(statusOf(res)).toBe(404); + expect(h.writes).toEqual([]); + }); + + it('refuses a one-letter-off body key rather than inverting it into "enable"', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enable: false }); + + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.writes).toEqual([]); + }); + + it('refuses a string `enabled`', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enabled: 'false' }); + + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.writes).toEqual([]); + }); + + it('an empty body switches ON (the activation body default)', async () => { + const h = boot('single'); + + // `null`, not `undefined`: the helper's default would stand in for `undefined`. + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, null); + + expect(statusOf(res)).toBe(200); + expect(h.tables[LEDGER]).toEqual([expect.objectContaining({ name: POSITION, active: true })]); + }); + + it('an undeclared name is 404 and writes nothing', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), '/_activation/permission/no_such_set'); + + expect(statusOf(res)).toBe(404); + expect(messageOf(res)).toContain("'no_such_set'"); + expect(h.writes).toEqual([]); + }); + + it('a name of the OTHER type is 404 — the ledger row is keyed by type', async () => { + const h = boot('single'); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${SET}`); + + expect(statusOf(res)).toBe(404); + expect(h.writes).toEqual([]); + }); + + it('no catalog bound is 503 SERVICE_UNAVAILABLE, never a write for an unconfirmed name', async () => { + const h = boot('single', { unbound: true }); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`); + + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); + + it('no ledger object in the composition is 501: the resolver would read no row, so no 200', async () => { + const h = boot('single', { omitLedger: true }); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`); + + expect(statusOf(res)).toBe(501); + expect(h.writes).toEqual([]); + }); + + it('carries the definition\'s package onto the row', async () => { + const h = boot('single'); + + await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`); + + expect(h.tables[LEDGER][0]).toEqual(expect.objectContaining({ package_id: 'crm' })); + }); + + it('re-enabling UPDATES the one row — the ledger keeps the choice, it does not grow a second row', async () => { + const h = boot('single'); + + await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enabled: false }); + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enabled: true }); + + expect(statusOf(res)).toBe(200); + expect(h.tables[LEDGER]).toHaveLength(1); + expect(h.tables[LEDGER][0]).toEqual(expect.objectContaining({ metadata_type: 'position', active: true })); + expect(h.writes.map((w) => w.op)).toEqual(['insert', 'update']); + }); + + it('a hook refusal on the ledger keeps its own status and code (the last-admin guard\'s 403)', async () => { + const refusal = Object.assign( + new Error("PERMISSION_DENIED: Refusing this 'sys_metadata_activation' write"), + { code: 'PERMISSION_DENIED', status: 403 }, + ); + const h = boot('single', { writeError: refusal }); + + const res = await flip(h, TENANT_ADMIN(), '/_activation/permission/admin_full_access'); + + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(h.tables[LEDGER]).toEqual([]); + }); + + it('a store failure is 503 SERVICE_UNAVAILABLE, never a 200', async () => { + const h = boot('single', { writeError: new Error('connection reset') }); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`); + + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + }); + + it('leaves every other `/security` route to the security service', async () => { + const h = boot('single'); + + const res = await h.dispatcher.handleSecurity('/suggested-bindings', 'GET', undefined, {}, TENANT_ADMIN()); + + // No `security` service in this composition: the existing 503, not this door. + expect(statusOf(res)).toBe(503); + expect(messageOf(res)).toBe('Security service not available'); + }); +}); + +describe('the catalog activation door — the resolver honours what it writes', () => { + it('CONTROL: before any flip the holder is granted through the position and the direct set', async () => { + const h = boot('single'); + + const grants = await grantsOf(h); + + expect(grants.permissions).toEqual(expect.arrayContaining([SET, DIRECT_SET])); + expect(grants.systemPermissions).toEqual(expect.arrayContaining(['cap_sales', 'cap_reports'])); + }); + + it('a position switched off stops granting through it; switched back on, it grants again', async () => { + const h = boot('single'); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enabled: false }))).toBe(200); + const off = await grantsOf(h); + expect(off.positions).not.toContain(POSITION); + expect(off.permissions).not.toContain(SET); + expect(off.systemPermissions).not.toContain('cap_sales'); + // Only that position: the direct set still grants. + expect(off.permissions).toContain(DIRECT_SET); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`, { enabled: true }))).toBe(200); + const on = await grantsOf(h); + expect(on.positions).toContain(POSITION); + expect(on.permissions).toContain(SET); + expect(on.systemPermissions).toContain('cap_sales'); + }); + + it('a permission set switched off stops granting, by every path that reaches it', async () => { + const h = boot('single'); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/permission/${DIRECT_SET}`))).toBe(200); + const directOff = await grantsOf(h); + expect(directOff.permissions).not.toContain(DIRECT_SET); + expect(directOff.systemPermissions).not.toContain('cap_reports'); + expect(directOff.permissions).toContain(SET); + + // The set a position distributes: the position stays held, the set stops granting. + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/permission/${SET}`))).toBe(200); + const viaPositionOff = await grantsOf(h); + expect(viaPositionOff.positions).toContain(POSITION); + expect(viaPositionOff.permissions).not.toContain(SET); + expect(viaPositionOff.systemPermissions).not.toContain('cap_sales'); + }); +}); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts new file mode 100644 index 00000000000..9b308dd0765 --- /dev/null +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] THE ACTIVATION DOOR for the + * security catalog: `POST /security/_activation/:type/:name`, body + * `{ enabled?: boolean }`, `:type` one of `position` / `permission`. + * + * ## Why it exists + * + * ADR-0131 D3 moves the on/off switch of a position and of a permission set off + * the catalog row and into the deployment-level activation ledger, + * `sys_metadata_activation` — one row per `(metadata_type, name)`, no tenant + * column. `resolveUserAuthzGrants` reads that ledger for both types and never + * the catalog row's `active` column (`readDisabledCatalogNames`, + * `@objectstack/core`'s `security/resolve-authz-context.ts`). Before this door + * nothing wrote the ledger for these two types — only the flow and action + * stores did — so a deactivation switched nothing off. This is the write half + * of that read. + * + * ## The spelling, and why it is the action door's + * + * Every activation door shares the verb (`POST`), the body + * (`{ enabled?: boolean }`, {@link readActivationBody}) and the authority + * ({@link refuseUngrantedActivationAuthoring}, then + * {@link refuseUngrantedActivationWrite}). The path mirrors the action door, + * `POST /actions/_activation/:object/:action`: a reserved `_activation` first + * segment, then the address the ledger keys a row by. Machine names cannot + * begin with `_` (`SnakeCaseIdentifierSchema`), so the segment collides with + * nothing this domain serves, and `:type` is the ledger's own `metadata_type` + * value — the two catalog types the resolver honours, nothing wider. + * + * ## What it writes, and what it deliberately does not + * + * One `sys_metadata_activation` row through the shared + * `ObjectStoreMetadataActivationStore` — the same row contract the flow and + * action stores use. ⛔ No definition is touched, ⛔ no catalog row's `active` + * is written (ADR-0131 D3: that column is not the switch any more), ⛔ no clone + * is authored. The write goes through the engine, so every hook on the ledger + * runs: the last-admin guard refuses switching `admin_full_access` off + * (ADR-0135 D5.2), and the grants cache drops what it holds for the ledger. + * + * Who may call it is exactly who may call the flow and action doors: the + * caller's `manage_metadata`, then the ADR-0126 §5 posture rule (inert under + * `single`, the platform operator under a wall). ⛔ No new authority. + */ + +import { AUDIENCE_ANCHOR_POSITIONS, GUEST_POSITION } from '@objectstack/spec/identity'; +import { + METADATA_ACTIVATION_TABLE, + ObjectStoreMetadataActivationStore, + securityCatalogReaderOf, + type SecurityCatalogEntry, +} from '@objectstack/core'; +import { + refuseUngrantedActivationWrite, + refuseUngrantedActivationAuthoring, + readActivationBody, + POSITION_ACTIVATION_SUBJECT, + PERMISSION_SET_ACTIVATION_SUBJECT, + type ActivationSubject, +} from './activation-gate.js'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; + +/** The reserved first segment of the door (see the module doc). */ +export const CATALOG_ACTIVATION_SEGMENT = '_activation'; + +/** + * The ledger types this door writes, each with its refusal wording. Exactly the + * types the resolver reads from the ledger; `capability` is code-only on the + * regime and has no switch. + */ +const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { + position: POSITION_ACTIVATION_SUBJECT, + permission: PERMISSION_SET_ACTIVATION_SUBJECT, +}; + +type CatalogActivationType = keyof typeof CATALOG_ACTIVATION_SUBJECTS; + +function isCatalogActivationType(type: string): type is CatalogActivationType { + return Object.prototype.hasOwnProperty.call(CATALOG_ACTIVATION_SUBJECTS, type); +} + +/** The catalog noun a sentence names, per type. */ +const NOUN: Readonly> = { + position: 'Position', + permission: 'Permission set', +}; + +/** + * Does this request belong to the door? Every `POST` under `_activation`, with + * no upper bound on depth, so the gate is exactly as wide as the arm: a + * wrong-shaped path is refused by the arm with the shape named, never passed on. + */ +export function isCatalogActivationWrite(parts: readonly string[], method: string): boolean { + return method === 'POST' && parts[0] === CATALOG_ACTIVATION_SEGMENT; +} + +/** Whether the engine's registry says the ledger object is absent from this composition. */ +function ledgerUnregistered(ql: any): boolean { + const getObject = ql?.registry?.getObject; + if (typeof getObject !== 'function') return false; + try { + return !getObject.call(ql.registry, METADATA_ACTIVATION_TABLE); + } catch { + return false; // a registry that cannot answer is not an answer: write, and let the write decide + } +} + +/** + * `POST /security/_activation/:type/:name` — flip one position's or permission + * set's deployment-level activation row. + * + * Order of operations, each step where it is for the reason given: + * + * 1. **Both authority gates, first** — ahead of the path and body checks and + * of any lookup, so a refused caller writes nothing and learns nothing: + * neither the body contract nor whether the name exists here. The refusal + * names the subject the PATH asked for; a path whose type is not a catalog + * type is refused with the position wording, which says nothing a refused + * caller could not read off the route ledger. + * 2. **Shape**: `['_activation', type, name]`, `type` a catalog type, and + * not an audience anchor (`everyone` / `guest`), which this door refuses. + * 3. **Body**: {@link readActivationBody}. + * 4. **Declaration**, through the catalog reader the security plugin binds to + * the engine — the one `resolveUserAuthzGrants` reads, so this door and + * the resolver cannot disagree about which names exist. No reader bound, + * or a reader that cannot answer ⇒ 503 (an outage is not a verdict); + * unknown ⇒ 404. + * 5. **The durable write**, through the engine. + */ +export async function handleCatalogActivationWrite( + deps: DomainHandlerDeps, + parts: readonly string[], + body: unknown, + context: HttpProtocolContext, +): Promise { + const requestedType = parts[1] ?? ''; + const subject = isCatalogActivationType(requestedType) + ? CATALOG_ACTIVATION_SUBJECTS[requestedType] + : POSITION_ACTIVATION_SUBJECT; + + // ── 1. authority ──────────────────────────────────────────────────────── + const authoringRefusal = refuseUngrantedActivationAuthoring(deps, context, subject); + if (authoringRefusal) return authoringRefusal; + const postureRefusal = await refuseUngrantedActivationWrite(deps, context, subject); + if (postureRefusal) return postureRefusal; + + // ── 2. shape ──────────────────────────────────────────────────────────── + if (parts.length !== 3 || !isCatalogActivationType(requestedType) || !parts[2]) { + return { + handled: true, + response: deps.error( + `Path must be /security/_activation/:type/:name, with :type one of ` + + `${Object.keys(CATALOG_ACTIVATION_SUBJECTS).map((t) => `\`${t}\``).join(', ')}`, + 400, + ), + }; + } + const type: CatalogActivationType = requestedType; + const name = parts[2]; + + // ── 2b. the audience anchors ──────────────────────────────────────────── + // [ADR-0090 D5/D9] `everyone` is the authenticated baseline every signed-in + // member holds implicitly, and `guest` the anonymous one. One row here + // would switch either off for every principal in the deployment at once. + // Stage 1 kept the anchors sealed against every caller-reachable write, and + // this door keeps that: refused in either direction, before the body is + // read and before any lookup or write, with the same 400 as a type that has + // no switch — a well-formed target this door does not switch. + if (type === 'position' && (AUDIENCE_ANCHOR_POSITIONS as readonly string[]).includes(name)) { + return { + handled: true, + response: deps.error( + `Position '${name}' is an audience anchor: it carries the ${name === GUEST_POSITION ? 'anonymous' : 'authenticated'} ` + + `baseline of every principal in the deployment, so it is not switchable through this door (ADR-0090 D5/D9). ` + + `Change what it grants through the permission sets bound to it instead.`, + 400, + ), + }; + } + + // ── 3. body ───────────────────────────────────────────────────────────── + const bodyReading = readActivationBody(deps, body); + if (bodyReading.refusal) return bodyReading.refusal; + const enabled = bodyReading.enabled; + + // ── 4. declaration ────────────────────────────────────────────────────── + if (!context.environmentId) { + const def = deps.getDefaultEnvironmentId(); + if (def) context.environmentId = def; + } + const ql: any = (await deps.resolveProjectKernelObjectQL(context)) + ?? await deps.getObjectQL(context, context.environmentId); + if (!ql) { + return { handled: true, response: deps.error('Data engine not available', 503) }; + } + const reader = securityCatalogReaderOf(ql); + if (!reader) { + return { + handled: true, + response: deps.error( + `Cannot verify the definition of ${NOUN[type].toLowerCase()} '${name}' — no security catalog is bound to ` + + `this deployment's data engine. Refusing rather than writing an activation row for a name nobody can ` + + `confirm exists.`, + 503, + { code: 'SERVICE_UNAVAILABLE' }, + ), + }; + } + let entry: SecurityCatalogEntry | undefined; + try { + entry = await reader.resolve(type, name); + } catch (err: any) { + return { + handled: true, + response: deps.error( + `Cannot verify the definition of ${NOUN[type].toLowerCase()} '${name}' — the security catalog could not ` + + `be read (${err?.message ?? String(err)}). Refusing rather than writing an activation row for a name ` + + `nobody can confirm exists.`, + 503, + { code: 'SERVICE_UNAVAILABLE' }, + ), + }; + } + if (!entry) { + return { + handled: true, + response: deps.error( + `${NOUN[type]} '${name}' has no definition — there is nothing to switch ${enabled ? 'on' : 'off'}. ` + + `The activation ledger addresses DECLARED catalog items (ADR-0126 §4).`, + 404, + ), + }; + } + + // ── 5. the durable write ──────────────────────────────────────────────── + if (ledgerUnregistered(ql) || typeof ql.find !== 'function' || typeof ql.insert !== 'function' + || typeof ql.update !== 'function') { + // The resolver reads an absent ledger as "nothing switched off", so a + // 200 here would report a switch that never existed. + return { + handled: true, + response: deps.error( + `This deployment does not carry the activation ledger (${METADATA_ACTIVATION_TABLE}, ADR-0126 §4), ` + + `so ${NOUN[type].toLowerCase()} '${name}' cannot be switched ${enabled ? 'on' : 'off'} here.`, + 501, + ), + }; + } + try { + await new ObjectStoreMetadataActivationStore(ql, type).setActive({ + name: entry.name, + packageId: entry.packageId ?? '', + active: enabled, + }); + } catch (err: any) { + // A hook's refusal carries its own status and code (the last-admin + // guard: 403 PERMISSION_DENIED); anything else is a store failure, and + // a failed write must never read as a successful flip. + const status = typeof err?.status === 'number' ? err.status : 503; + const code = typeof err?.code === 'string' ? err.code : 'SERVICE_UNAVAILABLE'; + return { + handled: true, + response: deps.error(err?.message ?? String(err), status, { code }), + }; + } + return { handled: true, response: deps.success({ type, name: entry.name, enabled }) }; +} diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index aeb10ac3448..9b65f578bb5 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -18,12 +18,16 @@ * GET /security/suggested-bindings?status=&packageId= → list (reconciles first) * POST /security/suggested-bindings/:id/confirm → create the anchor binding * POST /security/suggested-bindings/:id/dismiss → decline the suggestion + * POST /security/_activation/:type/:name → switch a position (`position`) or a + * permission set (`permission`) on/off + * for this deployment (`./catalog-activation.ts`) */ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, isAudienceBindingSuggestionStatus, unknownAudienceBindingSuggestionStatusMessage, } from '@objectstack/core'; +import { isCatalogActivationWrite, handleCatalogActivationWrite } from './catalog-activation.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -82,6 +86,18 @@ export async function handleSecurityRequest( }; } + // [ADR-0126 §3 regime C, ADR-0131 D3/D6] THE CATALOG ACTIVATION DOOR — + // switch a position or a permission set on/off for this deployment, which + // is a `sys_metadata_activation` row and nothing else. Answered ahead of + // the `security` service probe because it does not use that service: it + // writes the ledger through the engine, behind the same two authority + // gates as the flow and action doors (`./catalog-activation.ts`). + // split+filter, as below, so no regex runs over request-controlled input. + const activationParts = path.split('/').filter(Boolean); + if (isCatalogActivationWrite(activationParts, method.toUpperCase())) { + return handleCatalogActivationWrite(deps, activationParts, _body, context); + } + // [#4127 batch 3] The `as any` was the only thing between this call and // `ISecurityService`. The contract was written, `plugin-security` registers // the slot, and all three methods used below were already declared — the diff --git a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts index 0a1c2e3b847..1d7e0526339 100644 --- a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts +++ b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts @@ -40,14 +40,15 @@ // check. So each pin enters at the door body (`handleKeys` / `handleActions`), // which is where the gate it is about actually runs. // -// ## WHICH DOORS — three, because the gate body has three call sites +// ## WHICH DOORS — four, because the gate body has four call sites // -// `refuseUngrantedActivationWrite` is ONE gate with two callers, so the -// install-wide activation write has two doors: `./actions.ts:156` (`POST -// /actions/_activation/:object/:action`) and `./automation.ts:1050` (`POST -// /automation/:name/toggle`, through `refuseUngrantedFlowActivationWrite`). -// Both inherit the throw exit this change gives the gate, so both are pinned -// here alongside the `/keys` mint. A pin on one door is not evidence about the +// `refuseUngrantedActivationWrite` is ONE gate with three callers, so the +// install-wide activation write has three doors: `./actions.ts` (`POST +// /actions/_activation/:object/:action`), `./automation.ts` (`POST +// /automation/:name/toggle`, through `refuseUngrantedFlowActivationWrite`) and +// `./catalog-activation.ts` (`POST /security/_activation/:type/:name`, for +// positions and permission sets). All inherit the throw exit this change gives +// the gate, so all are pinned here alongside the `/keys` mint. A pin on one door is not evidence about the // other: they differ in what runs in FRONT of the gate — the automation domain // has an anonymous floor and its own authoring-write predicate — and what runs // in front of a gate is exactly what a pin entering at the door body measures. @@ -73,8 +74,8 @@ // // Covers: three wirings of `tenancy` (never registered · registered through a // factory that throws · registered SCOPED and healthy, reporting `isolated`) -// across three doors (`POST /keys` mint · the actions activation write · the -// automation toggle), for the callers each door's decision turns on (an org-less +// across four doors (`POST /keys` mint · the actions activation write · the +// automation toggle · the catalog activation write), for the callers each door's decision turns on (an org-less // minter; a tenant org admin who already holds `manage_metadata`; the // `PLATFORM_ADMIN` operator). // @@ -101,10 +102,12 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectKernel, ServiceLifecycle, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import { ACTIVATION_DENY_STATUS, ACTIVATION_DENY_CODE } from './activation-gate.js'; +import { bindTestSecurityCatalog } from '../security/security-catalog.testkit.js'; /** ADR-0112 envelope for the outage answer — the brand's own two fields. */ const OUTAGE_STATUS = 503; @@ -576,3 +579,97 @@ describe('[#15900] the automation toggle — the same install-wide gate, reached expect(toggleFlow).toHaveBeenCalled(); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// Gate 4 — the THIRD door onto the same install-wide write: +// `POST /security/_activation/:type/:name` (`./catalog-activation.ts`), which +// calls the gate directly, as the action door does. What runs in front of it +// differs again — the `/security` domain's anonymous floor — so it is pinned +// on its own rather than inferred from the other two. + +const CATALOG_POSITION = 'sales_rep'; + +/** + * The engine the catalog door writes through: the ledger store's keyed find, + * insert and by-id update, with a security catalog bound that declares one + * position. `update` routes through the real engine's dispatch + * (`check:engine-double-contract`). + */ +function bootCatalogActivation(wiring: TenancyWiring) { + const ledger: Array> = []; + const ql = bindTestSecurityCatalog({ + registry: { getObject: (name: string) => (name === 'sys_metadata_activation' ? { name } : undefined) }, + find: async (_obj: string, opts: any) => { + const where = opts?.where ?? {}; + const matched = ledger.filter((r) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + })); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + insert: async (_obj: string, data: any) => { + const row = { id: `act_${ledger.length + 1}`, ...data }; + ledger.push(row); + return row; + }, + update: async (_obj: string, data: any, options?: any) => { + const dispatch = assertEngineUpdateDispatch(data, options); + if (dispatch.kind !== 'by-id') throw new Error('fake engine: only by-id updates are modelled'); + const row = ledger.find((r) => r.id === dispatch.id); + if (row) Object.assign(row, data); + return row; + }, + }, { positions: [{ name: CATALOG_POSITION, permissionSets: [] }] }); + const kernel = bareKernel(); + kernel.registerService('objectql', ql); + kernel.registerService('data', ql); + wireTenancy(kernel, wiring); + return { dispatcher: new HttpDispatcher(kernel as never), ledger }; +} + +const flipPosition = (dispatcher: HttpDispatcher, ctx: HttpProtocolContext) => + dispatcher.handleSecurity(`/_activation/position/${CATALOG_POSITION}`, 'POST', { enabled: false }, {}, ctx); + +describe('[#15900] the catalog activation write — the same install-wide gate, reached through the THIRD door', () => { + it('REFUSES loudly (503, outage brand) and writes no ledger row when the `tenancy` factory throws', async () => { + const { dispatcher, ledger } = bootCatalogActivation('throwing-factory'); + + const err = await flipPosition(dispatcher, tenantAdmin()).then( + () => { throw new Error('the catalog activation door answered instead of refusing'); }, + (e: unknown) => e, + ); + + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect((err as { status?: unknown }).status).toBe(OUTAGE_STATUS); + expect((err as { code?: unknown }).code).toBe(OUTAGE_CODE); + expect(ledger).toEqual([]); + }); + + it('CONTROL — a deployment that never registered `tenancy` still permits the org admin', async () => { + const { dispatcher, ledger } = bootCatalogActivation('never-registered'); + + const res = responseOf(await flipPosition(dispatcher, tenantAdmin())); + + expect(res.status).toBe(200); + expect(ledger).toEqual([expect.objectContaining({ metadata_type: 'position', name: CATALOG_POSITION, active: false })]); + }); + + it('reads a HEALTHY SCOPED `tenancy` through the request scope and REFUSES the tenant org admin (403), not 503', async () => { + const { dispatcher, ledger } = bootCatalogActivation('scoped-healthy'); + + const res = responseOf(await flipPosition(dispatcher, tenantAdmin())); + + expect(res.status).toBe(ACTIVATION_DENY_STATUS); + expect(res.body?.error?.code).toBe(ACTIVATION_DENY_CODE); + expect(ledger).toEqual([]); + }); + + it('reads a HEALTHY SCOPED `tenancy` through the request scope and ADMITS the platform operator (200)', async () => { + const { dispatcher, ledger } = bootCatalogActivation('scoped-healthy'); + + const res = responseOf(await flipPosition(dispatcher, operator())); + + expect(res.status).toBe(200); + expect(ledger).toHaveLength(1); + }); +}); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 4146382d41e..cb05b10a0a8 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 84 rows. + * CENSUS (generated): this list holds 85 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -345,6 +345,20 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' }, { route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.confirm' }, { route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.dismiss' }, + { route: 'POST /security/_activation/:type/:name', domain: '/security', disposition: 'server-only', + note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`) or permission set ' + + '(`:type` = `permission`) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + + 'nothing else (⛔ no definition write, ⛔ no catalog row `active`: ADR-0131 D3 moved the switch into the ledger, and ' + + '`resolveUserAuthzGrants` reads only the ledger for these two types). Body `{ enabled?: boolean }`, the same reader as ' + + 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: ' + + '`manage_metadata`, then the ADR-0126 §5 posture rule requiring the platform operator in `group`/`isolated` — one shared ' + + 'implementation in `domains/activation-gate.ts`. The name must resolve in the security catalog the resolver reads (404 ' + + 'otherwise, 503 when no catalog is bound), and the audience anchors `everyone` / `guest` are refused 400 in either direction; switching `admin_full_access` off is refused 403 by the last-admin guard\'s ' + + 'ledger hook. The ONE `/security` route the dispatcher mounts on the wire (`registerSecurityActivationRoutes`); the rest ' + + 'of `/security` is the REST server\'s. NOT JS-SDK surface, for the action door\'s reason: its caller is the Setup console ' + + '(Deactivate on the position and permission-set pages), which calls the platform API directly; adding a client method ' + + 'reclassifies this row to `sdk`. Pinned in `domains/catalog-activation-door.test.ts` and ' + + '`qa/dogfood/test/catalog-activation-door.dogfood.test.ts`' }, // ── keys ────────────────────────────────────────────────────────────────── { route: 'POST /keys', domain: '/keys', disposition: 'sdk', client: 'keys.create', diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index e7cfe45d9e6..4f72285a37b 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -464,10 +464,11 @@ const DISPATCHER_DOMAIN_DIR = 'packages/runtime/src/domains'; const DISPATCHER_DOMAINS = { 'actions.ts': { handBuilt: 0 }, // [#12160] Not a domain either — the ADR-0126 §5 activation write-authority - // gates, shared by the `/automation` toggle door and the `/actions` - // activation door so one security policy has one implementation. Like - // `unavailable.ts` it answers refusals through `deps.error`, which is this - // check's own thesis applied to a refusal that two domains emit. + // gates, shared by the `/automation` toggle door, the `/actions` activation + // door and the `/security` catalog activation door so one security policy + // has one implementation. Like `unavailable.ts` it answers refusals through + // `deps.error`, which is this check's own thesis applied to a refusal that + // three domains emit. 'activation-gate.ts': { handBuilt: 0 }, 'analytics.ts': { handBuilt: 0 }, // [#22576] The `/approvals/act` bridge: the approvals member's own HTML @@ -476,6 +477,10 @@ const DISPATCHER_DOMAINS = { // `deps.error`. 'approvals.ts': { handBuilt: 0 }, 'automation.ts': { handBuilt: 0 }, + // The `/security` domain's catalog activation door (ADR-0126 §3 regime C, + // ADR-0131 D6), split out of `security.ts` beside the gate it calls. Every + // answer it gives goes through `deps.success` / `deps.error`. + 'catalog-activation.ts': { handBuilt: 0 }, 'data.ts': { handBuilt: 0 }, 'i18n.ts': { handBuilt: 0 }, 'notifications.ts': { handBuilt: 0 }, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index daa9b00bc62..1dfe895cae7 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3861,6 +3861,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/domains/catalog-activation-door.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/domains/meta-published-runtime-publish.test.ts", "verb": "delete", @@ -3931,6 +3936,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/http-dispatcher.keys.test.ts", "verb": "delete",