Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
7b51812
wip: PositionSchema.permissionSets; resolver reads positions, sets an…
claude Oct 10, 2026
8cbedf9
wip: SecurityPlugin binds the catalog read; everyone declares the bas…
claude Oct 10, 2026
1e3a4b1
wip: conversion function, static catalog testkit, golden query move r…
claude Oct 10, 2026
d6a43e5
wip: core suites read the catalog; admin-standing surface re-measured
claude Oct 10, 2026
df2ac6b
wip: runtime and plugin-security fixtures bind the catalog
claude Oct 10, 2026
4da892e
wip: plugin-security fixtures declare the catalog; everyone baseline …
claude Oct 10, 2026
ead16a0
wip: rest fixtures declare the catalog
claude Oct 10, 2026
cb7e21d
wip: plugin-auth fixtures declare the catalog
claude Oct 10, 2026
8dca0b1
wip: hono-server, sharing and automation fixtures declare the catalog
claude Oct 10, 2026
35e8fcd
wip: example personas declare permissionSets; spec docs and liveness …
claude Oct 10, 2026
58d33e5
fix(plugin-security): a Setup row edit carries the definition's permi…
claude Oct 10, 2026
4362ea1
wip: cloud-connection fixtures declare the catalog
claude Oct 10, 2026
e9fc856
wip: service-datasource, service-settings and approvals fixtures decl…
claude Oct 10, 2026
4a2d686
wip: changeset; pin the walled promotion path through the metadata door
claude Oct 10, 2026
e6baf14
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
af4aa34
test: compact the per-package security-catalog test helper
claude Oct 10, 2026
15f5c97
spec: register the position-permission-sets-declared ADR-0087 semanti…
claude Oct 10, 2026
7564191
spec: the position form offers permissionSets
claude Oct 10, 2026
56d4949
core: deactivation of positions and permission sets is read from the …
claude Oct 10, 2026
4726c1e
changeset: deactivation moves to the activation ledger
claude Oct 10, 2026
6e05833
cloud-connection: the fake engines answer the activation ledger
claude Oct 10, 2026
862b773
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
997aaa6
plugin-security: the #18535 test registry stamps the package id
claude Oct 10, 2026
055f5df
platform-objects: regenerate the metadata-form bundles for permission…
claude Oct 10, 2026
dca5407
platform-objects: translate the position form's permissionSets row in…
claude Oct 10, 2026
83b54d8
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
0aaaf61
runtime: activation door for positions and permission sets on sys_met…
claude Oct 10, 2026
74cf4b5
runtime: pin the catalog activation door's authority, contract and re…
claude Oct 10, 2026
e4b1a46
runtime: pin the catalog activation door on the shared tenancy-outage…
claude Oct 10, 2026
96feef2
qa/dogfood: pin the catalog activation door on a booted showcase
claude Oct 10, 2026
3e4b7fe
changeset: runtime minor for the catalog activation door
claude Oct 10, 2026
3faa6a0
qa/dogfood: the catalog door's resolver leg provisions its own holder…
claude Oct 10, 2026
96fb3e9
patch round 1: the four red CI packages, and the changeset names the …
claude Oct 10, 2026
dd8e0c4
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
e053f2c
gates: declare the catalog activation module to check:route-envelope;…
claude Oct 10, 2026
fb2855f
changeset: platform-objects ships the regenerated position form bundles
claude Oct 10, 2026
5bf250a
qa/dogfood: the authz matrix docblock reads 85 dispatcher ledger rows
claude Oct 10, 2026
1b58797
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
2c3a598
docs: a position's sets are its permissionSets; a junction row grants…
claude Oct 10, 2026
bf6bdf0
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
a71d20d
qa/dogfood: the authz blind-spot census reads 85 dispatcher ledger rows
claude Oct 10, 2026
306de5f
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
638ea68
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 2026
d73144f
docs: name the security catalog activation route where the ledger swi…
claude Oct 11, 2026
2c2b3f4
core: row-active.ts says what is true after stage 2 — the catalog row…
claude Oct 11, 2026
6b3a6c0
metadata-protocol: a packaged position's refusal names the security c…
claude Oct 11, 2026
faf0c20
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 2026
44bb1e1
dogfood: the admin_full_access case pins the last-admin guard's ruled…
claude Oct 11, 2026
5af1179
runtime,metadata-protocol: the catalog activation door refuses the au…
claude Oct 11, 2026
112937e
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 2026
a27c4db
metadata-protocol: drop the position regime row — a packaged position…
claude Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/15204-s2c-catalog-activation-door.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
25 changes: 17 additions & 8 deletions packages/core/src/security/row-active.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
*
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<Record<string, unknown>>>;
insert(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;
update(object: string, data: Record<string, unknown>, options?: unknown): Promise<unknown>;
}

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');
});
});
Loading
Loading