diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md index f00986eb63d..fca239c4463 100644 --- a/.changeset/15204-s1-position-permission-sets.md +++ b/.changeset/15204-s1-position-permission-sets.md @@ -37,7 +37,7 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). -**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A new hook refuses a ledger write that switches `admin_full_access` off (`refuseLedgerSwitchingAdminOff`). Its `sys_permission_set` hooks stay registered, unchanged. +**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. diff --git a/.changeset/15204-s2b-plugin-auth-catalog-readers.md b/.changeset/15204-s2b-plugin-auth-catalog-readers.md new file mode 100644 index 00000000000..7a1ae3d843a --- /dev/null +++ b/.changeset/15204-s2b-plugin-auth-catalog-readers.md @@ -0,0 +1,24 @@ +--- +'@objectstack/plugin-auth': minor +--- + +feat(plugin-auth)!: the last-administrator guard, the self-registration admission and the default-organization step read the security catalog and the activation ledger, never a `sys_permission_set` row (ADR-0131 D3/D4) + +Clause-②: yes (narrowing: a zero-administrator write is refused when an unscoped grant names a set the security catalog does not hold, and self-registration is refused when its declared set is not in the catalog or is switched off in the activation ledger; widening: writes to a `sys_permission_set` row are no longer judged by the guard, and a ledger write switching `admin_full_access` off is refused only when it would leave no administrator) + + + +**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. It finishes, for `plugin-auth`, the move the authorization resolver made in the same release: a permission set exists when the security catalog bound to the engine holds its definition, and it is switched off by a `sys_metadata_activation` row of type `permission` whose `active` is false. No `sys_permission_set` row decides either any more, its `active` column included. + +**The last-administrator guard.** + +- A grant-anchored platform administrator is counted the way the resolver derives one: an unscoped, in-window grant naming `admin_full_access`, which the catalog holds and the ledger leaves on. An engine with no catalog bound counts none, as the resolver grants none. +- Switching `admin_full_access` off in the ledger (an insert, an update of its row, or an update moving another row onto that pair) is judged like every other standing write. It is refused only when it would leave no administrator who can sign in. A config-anchored or organization administrator still standing permits it. The flat refusal of every such write is gone, and with it `refuseLedgerSwitchingAdminOff`. +- Writes to a `sys_permission_set` row (delete, rename, `active`, `organization_id`) are no longer judged. The resolver reads no row, so none of them moves an administrator. `PERMISSION_SET_STANDING_KEYS` is removed, and with it the guard's two `sys_permission_set` hooks. +- With zero administrators, the guard still tells the bootstrap window from an emptied environment. The evidence is now an unscoped, in-window grant naming a set the catalog does not hold (or naming none), or `admin_full_access` switched off in the ledger while such grants name it. Its remedy is to switch the set back on, and that write is permitted. + +Two exports of the guard are removed, with nothing in their place: `PERMISSION_SET_STANDING_KEYS` (the table it judged is no longer read) and `refuseLedgerSwitchingAdminOff` (`registerLastAdminGuard` judges ledger writes itself). No package in this repository imports either outside `plugin-auth`. + +**Self-registration.** The admission asks whether the declared `selfRegistrationPermissionSet` would grant: the catalog holds it and the ledger leaves it on. A `sys_permission_set` row of that name must also exist, because the grant's `permission_set_id` is written with its id. The row's `active` is no longer read, at admission or when the grant is written. + +**The default organization.** The platform administrator the default-organization step binds as owner is found through a set the catalog holds and the ledger leaves on, not through a `sys_permission_set` row. diff --git a/.changeset/15204-s2b-plugin-sharing-ledger-deactivation.md b/.changeset/15204-s2b-plugin-sharing-ledger-deactivation.md new file mode 100644 index 00000000000..8d2d1ada8c3 --- /dev/null +++ b/.changeset/15204-s2b-plugin-sharing-ledger-deactivation.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-sharing': minor +--- + +feat(plugin-sharing)!: a sharing rule reads a position's deactivation from the activation ledger, as the authorization resolver does (ADR-0131 D3, ADR-0126 §4) + +Clause-②: yes (narrowing: a position switched off in the activation ledger stops receiving sharing-rule shares in every organization; widening: a position whose `sys_position` row reads `active: false` receives them again) + + + +**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. A position's deactivation now comes from `sys_metadata_activation` instead of the `sys_position` row's `active` column, so the shares a sharing rule materializes follow the same switch the authorization resolver reads. + +- A sharing rule whose recipient is a position materializes no share while that position is switched off. Switched off means a `sys_metadata_activation` row of type `position` naming it, with `active` false (a driver `0` included). The switch is deployment-wide, like the ledger, so it no longer depends on the rule's organization. +- The `sys_position` row's `active` is no longer read. A deactivation made in Setup writes that column, so until the upgrade ceremony converts it into a ledger row it stops neither the resolver nor sharing rules. +- A name with no ledger row keeps sharing, including a membership-derived position name. A composition that registers no ledger object issues no ledger read. A ledger read that fails still grants for the pass, as the row read did. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 7af47d16388..06ddf0b61e8 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -437,17 +437,19 @@ not re-derived — that would replicate the resolver's aggregation). **Absent is ACTIVE.** Only a ledger row whose `active` reads false takes a grant away; a permission set or position with no ledger row is in effect. The -last-administrator guard's enumeration still reads the catalogue row's -`active`, so it counts a row-deactivated `admin_full_access` as off and refuses -more than it must, never less. - -**Switching the break-glass set off is refused.** A ledger row switching -`admin_full_access` off would un-make every platform admin who holds it -*through a grant row*, in one write — and switching it back on needs the -permission just lost. The last-administrator guard refuses such a ledger -write outright, whatever the administrator population (ADR-0135 D5.2), and -refuses one whose target row it cannot read. Switching it back on is never -refused. +last-administrator guard reads the same two places the resolver does: whether +the catalogue holds `admin_full_access`, and whether the ledger switches it +off. It reads no catalogue row, so a row's `active` changes nothing it counts. + +**Switching the break-glass set off is refused while it would leave no +administrator.** A ledger row switching `admin_full_access` off un-makes every +platform admin who holds it *through a grant row*, in one write — and +switching it back on needs the permission just lost. The last-administrator +guard judges such a ledger write like every other write that can take +standing away (ADR-0135 D5.2): it is refused only when nobody who can sign in +would still administer the environment. A configured administrator or an +organization owner or admin still standing permits it. A ledger read that +fails refuses the write. Switching it back on is never refused. Deactivation is an incident-response control, so what it does **not** touch is deliberate. Administration surfaces keep listing and editing a deactivated diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts index 0a4da720a74..b0f7a9bd66d 100644 --- a/packages/plugins/plugin-auth/src/audience-posture.test.ts +++ b/packages/plugins/plugin-auth/src/audience-posture.test.ts @@ -23,6 +23,7 @@ import { describe, it, expect, vi } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; import { AuthManager } from './auth-manager'; +import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit.js'; import { assertAudienceConfig, classifyCreationMethod, @@ -113,11 +114,19 @@ const createMemoryEngine = () => { const SECRET = 'test-secret-at-least-32-chars-long!!'; const PASSWORD = 'S3cure!Passw0rd-4785'; +/** + * [ADR-0131 D3/D4] The sets the fixture's `sys_permission_set` rows name are + * catalog definitions too — the admission asks the catalog whether a set + * grants, and the grant is written with the row's id. + */ +const bindRowCatalog = (engine: any) => + bindTestSecurityCatalog(engine, () => catalogFromTables({ sys_permission_set: engine.tables.get('sys_permission_set') })); + const makeManager = (engine: any, config: Record = {}) => new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', - dataEngine: engine, + dataEngine: bindRowCatalog(engine), ...config, } as any); @@ -806,6 +815,37 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) expect((engine.tables.get('sys_user') ?? []).length).toBe(1); }); + it('[ADR-0131 D3] a declared set the activation ledger switches OFF refuses admission — never an ungranted admit', async () => { + const engine = createMemoryEngine(); + seedExistingUser(engine); + seedPermissionSet(engine, 'member_default'); + engine.tables.set('sys_metadata_activation', [ + { id: 'act_member', metadata_type: 'permission', name: 'member_default', package_id: 'pkg', active: false }, + ]); + const manager = makeManager(engine, { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }); + const res = await signUp(manager, 'user@anywhere.com'); + expect(res.status).toBe(403); + expect((await res.json()).code).toBe(AUDIENCE_CONFIG_ERROR); + expect((engine.tables.get('sys_user') ?? []).length).toBe(1); + }); + + it('[ADR-0131 D3] the set ROW\'s `active` switches nothing: a row flagged off still admits and grants', async () => { + // The resolver reads no catalog row, so the row flag is not a deactivation. + const engine = createMemoryEngine(); + seedExistingUser(engine); + seedPermissionSet(engine, 'member_default', { active: false }); + const manager = makeManager(engine, { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, + }); + const res = await signUp(manager, 'anyone@anywhere.com'); + expect(res.status).toBeLessThan(300); + await vi.waitFor(() => { + expect((engine.tables.get('sys_user_permission_set') ?? []).length).toBe(1); + }); + }); + it('open: self-serve admitted and granted', async () => { const engine = createMemoryEngine(); seedExistingUser(engine); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5b5d42a0363..39f1993a029 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -58,6 +58,7 @@ import { import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js'; +import { permissionSetInEffect } from './catalog-set-in-effect.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js'; import { @@ -4763,10 +4764,11 @@ export class AuthManager { const resolvable = await this.selfRegistrationSetResolvable(setName); if (!resolvable) { const message = - `the declared self-registration permission set '${setName}' cannot be resolved in ` + - 'sys_permission_set (missing, deactivated, or the permission-set store is unavailable) — ' + + `the declared self-registration permission set '${setName}' cannot be resolved ` + + '(not in the security catalog, switched off in sys_metadata_activation, without a ' + + 'sys_permission_set row to grant it by, or the store is unavailable) — ' + 'self-registration is refused rather than admitting an ungranted user. ' + - 'Create/activate the set, or point selfRegistrationPermissionSet at an existing one.'; + 'Declare or switch on the set, or point selfRegistrationPermissionSet at an existing one.'; this.audienceLogError(`[audience] ${message}`); return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message }; } @@ -5004,10 +5006,25 @@ export class AuthManager { } } - /** At least one ACTIVE `sys_permission_set` row carries the declared name. */ + /** + * [ADR-0131 D3/D4] The declared set would GRANT: the security catalog holds + * it and the activation ledger does not switch it off — the resolver's own + * reading ({@link permissionSetInEffect}) — and a `sys_permission_set` row + * carries the id the grant's required `permission_set_id` is written with + * ({@link settleSelfRegistrationGrant}). The row's `active` is not read. + * A read that fails answers `false`: the admission refuses. + */ private async selfRegistrationSetResolvable(setName: string): Promise { + const engine = this.config.dataEngine; + if (!engine || typeof (engine as any).find !== 'function') return false; + try { + const reader = withSystemReadContext(engine) as any; + if (!(await permissionSetInEffect(engine, (object, query) => reader.find(object, query), setName))) return false; + } catch { + return false; + } const rows = await this.findPermissionSetRows(setName); - return rows.some((r) => r?.active !== false && typeof r?.id === 'string' && r.id); + return rows.some((r) => typeof r?.id === 'string' && r.id); } private async findPermissionSetRows(setName: string): Promise { @@ -5238,12 +5255,14 @@ export class AuthManager { } catch { organizationId = null; } - // ACTIVE is a selection predicate — a deactivated set legitimately does - // not resolve, so it stays a filter. A missing or blank `id` is NOT a - // selection: it is a MALFORMED row, and dropping it silently is the + // [ADR-0131 D3] The row is read for the id the grant's required + // `permission_set_id` is written with, and for nothing else: whether the + // set grants was asked at admission, of the catalog and the activation + // ledger, and the row's `active` is not read. A missing or blank `id` is + // a MALFORMED row, and dropping it silently is the // opposite defect from a dead limb, wrong in two ways that both look // normal from outside. (1) The family narrows to nothing and the report - // below blames "no active row named X" while an active row named X is + // below blames "no row named X" while a row named X is // sitting right there — and that report is the only signal, because // nothing retries this. (2) Worse, when the malformed row is the // ORG-SCOPED one, the `organization_id == null` arm below then resolves @@ -5253,15 +5272,13 @@ export class AuthManager { // "unambiguous single row" this code believes it selected is not that. // So a malformed candidate REFUSES the grant and names itself, which is // the same gap-not-empty-answer direction the rest of this method takes. - const candidates = (await this.findPermissionSetRows(staged.setName)).filter( - (r) => r?.active !== false, - ); + const candidates = await this.findPermissionSetRows(staged.setName); const malformed = candidates.filter((r) => !(typeof r?.id === 'string' && r.id)); if (malformed.length > 0) { this.reportUngrantedSelfRegistrant( userId, staged.setName, - `${malformed.length} active sys_permission_set row(s) named '${staged.setName}' carry no usable id, ` + + `${malformed.length} sys_permission_set row(s) named '${staged.setName}' carry no usable id, ` + 'so which row this grant would resolve to cannot be decided — refusing rather than ' + 'silently dropping them and granting whichever row is left', ); @@ -5277,8 +5294,8 @@ export class AuthManager { userId, staged.setName, organizationId - ? `no active sys_permission_set row named '${staged.setName}' resolves for organization ${organizationId}` - : `no active sys_permission_set row named '${staged.setName}' resolves`, + ? `no sys_permission_set row named '${staged.setName}' resolves for organization ${organizationId}` + : `no sys_permission_set row named '${staged.setName}' resolves`, ); return; } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index f79026e085f..f80ed382aae 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -5,6 +5,7 @@ import { AuthPlugin } from './auth-plugin'; import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; describe('AuthPlugin', () => { let mockContext: PluginContext; @@ -1355,6 +1356,8 @@ describe('AuthPlugin', () => { hookCapture = createHookCapture(); middlewares = []; ql = makeQl(); + // [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition. + bindCatalogFromTables(ql, ql.tables); mockContext.hook = hookCapture.hookFn; mockContext.getService = vi.fn((name: string) => { if (name === 'manifest') return { register: vi.fn() }; @@ -1610,6 +1613,8 @@ describe('AuthPlugin', () => { delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL; hookCapture = createHookCapture(); ql = makeQl(); + // [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition. + bindCatalogFromTables(ql, ql.tables); mockContext.hook = hookCapture.hookFn; mockContext.getService = vi.fn((name: string) => { if (name === 'manifest') return { register: vi.fn() }; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8fb0ecc7416..844cc0b8dd5 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1515,12 +1515,12 @@ export class AuthPlugin implements Plugin { // actually lock an org out are the system ones (better-auth's admin // ban, remove-user and updateMemberRole, driven by a SCIM // `active: false` / `DELETE /Users/{id}` / group remap). Since #6084 it - // also covers `sys_permission_set`: deleting or renaming the - // `admin_full_access` row un-makes every platform admin at once, and - // the zero-admin bootstrap exemption then switched the guard off for - // all of the above. All eight hooks register at priority 20 so the - // ADR-0092 checks above (10) still answer first for user-context - // callers. See last-admin-guard.ts. + // also covers switching `admin_full_access` off — in the activation + // ledger since ADR-0131 D3 — which un-makes every grant-anchored + // platform admin at once, and the zero-admin bootstrap exemption would + // then switch the guard off for all of the above. Every hook registers + // at priority 20 so the ADR-0092 checks above (10) still answer first + // for user-context callers. See last-admin-guard.ts. registerLastAdminGuard(engine, { packageId: 'com.objectstack.plugin-auth.last-admin-guard', logger: ctx.logger, diff --git a/packages/plugins/plugin-auth/src/catalog-set-in-effect.ts b/packages/plugins/plugin-auth/src/catalog-set-in-effect.ts new file mode 100644 index 00000000000..98b7ac6b52b --- /dev/null +++ b/packages/plugins/plugin-auth/src/catalog-set-in-effect.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4, ADR-0126 §4] Whether a permission set NAME confers anything + * on this engine — asked the way the authorization resolver answers it + * (`resolveUserAuthzGrants`, `@objectstack/core`), so a plugin-auth reader that + * decides "would a grant of this set grant" cannot disagree with the resolver + * that grants it: + * + * - the set EXISTS when the security catalog bound to the engine + * (`securityCatalogReaderOf`) holds a `permission` definition of that name; + * an engine with no catalog bound holds none; + * - it is IN EFFECT unless the activation ledger (`sys_metadata_activation`, + * one row per `(metadata_type, name)`) carries a `permission` row of that + * name whose `active` reads false — a driver `0` included. No row means + * active; a composition that registers no ledger object, or a ledger table + * never provisioned, holds no row. + * + * ⛔ The `sys_permission_set` row's `active` column is not read: the resolver + * reads no catalog row, so a flag there switches nothing. + * + * A catalog or ledger read that fails throws; the caller decides its own + * failure direction. + */ + +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { securityCatalogReaderOf } from '@objectstack/core'; +import { isMissingTableError } from '@objectstack/types'; + +/** The activation ledger (ADR-0126 §4), reached by name. */ +const METADATA_ACTIVATION = 'sys_metadata_activation'; + +/** A system-context read of one object. */ +export type CatalogStandingRead = (object: string, query: EngineQueryOptions) => Promise; + +/** Whether the ledger switches `name` off. */ +async function switchedOff(engine: object, read: CatalogStandingRead, name: string): Promise { + const registry = (engine as { registry?: { getObject?: (object: string) => unknown } }).registry; + if (typeof registry?.getObject === 'function' && !registry.getObject(METADATA_ACTIVATION)) return false; + let rows: unknown; + try { + rows = await read(METADATA_ACTIVATION, { where: { metadata_type: 'permission', name }, limit: 1 }); + } catch (err) { + if (isMissingTableError(err, METADATA_ACTIVATION)) return false; + throw err; + } + const row = Array.isArray(rows) ? (rows[0] as { active?: unknown } | undefined) : undefined; + return row !== undefined && (row.active === false || row.active === 0); +} + +/** + * Whether the permission set `name` confers anything on `engine` (module doc). + * + * @param engine The ObjectQL engine the security plugin bound its catalog to. + * @param read The system-context read the ledger is asked through. + */ +export async function permissionSetInEffect(engine: object, read: CatalogStandingRead, name: string): Promise { + const catalog = securityCatalogReaderOf(engine); + if (!catalog || (await catalog.resolve('permission', name)) === undefined) return false; + return !(await switchedOff(engine, read, name)); +} diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts index 94fdcd8e80d..63050e629a6 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts @@ -37,6 +37,7 @@ import { } from './ensure-default-organization.js'; import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; import { DEFAULT_ORG_OWNER_BIND_MIGRATION_ID } from './membership-backfill-ledger.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // The legacy grant anchor this helper falls back to is posture-keyed, and the // config anchor reads `OS_PLATFORM_OWNER_EMAIL` live: pin both to the @@ -121,6 +122,8 @@ function rig(seed: Partial> = {}) { return row ?? null; }), }; + // [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition. + bindCatalogFromTables(ql, tables); const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; /** The options every `ensure` call received, in call order. */ const ensureCalls: EnsureDefaultOrganizationOptions[] = []; diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 3235d980f6d..252389b2c49 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -13,6 +13,7 @@ import { } from './ensure-default-organization.js'; import { ensureDefaultOrganizationExists } from './default-organization-invariant.js'; import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; +import { bindCatalogFromTables, bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; // [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on // the raw value), so every case in this file pins the variable's state instead @@ -88,7 +89,8 @@ function makeQl(seed: Partial> = {}) { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return v === null ? row[k] == null : row[k] === v; }); - return { + // [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition. + return bindCatalogFromTables({ tables, find: vi.fn(async (object: string, q: any) => (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), @@ -97,7 +99,7 @@ function makeQl(seed: Partial> = {}) { (tables[object] ??= []).push(data); return data; }), - }; + }, tables); } describe('ensureDefaultOrganization (plugin-auth home)', () => { @@ -117,6 +119,26 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { expect(ql.insert).not.toHaveBeenCalled(); }); + it('[ADR-0131 D3/D4] a set ROW the catalog does not hold anchors nobody — the derivation reads the catalog', async () => { + const ql = makeQl(); + bindTestSecurityCatalog(ql, { permissions: [{ name: 'member_default' }] }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('[ADR-0131 D3] a set the activation ledger switches off anchors nobody — the row\'s own flag is not read', async () => { + const ql = makeQl({ + sys_metadata_activation: [{ id: 'act_admin', metadata_type: 'permission', name: 'admin_full_access', active: false }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); + + // CONTROL — the same rig with the switch on binds the owner. + ql.tables.sys_metadata_activation[0].active = true; + expect(await ensureDefaultOrganization(ql)).toMatchObject({ memberCreated: true }); + }); + it('respects an admin who already belongs to an org', async () => { const ql = makeQl({ sys_member: [{ id: 'm0', user_id: 'u1', organization_id: 'org_x' }] }); const res = await ensureDefaultOrganization(ql); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 5f151ad095c..df86ce7f9b1 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -80,9 +80,11 @@ */ import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; +import { permissionSetInEffect } from './catalog-set-in-effect.js'; interface BootstrapLogger { info: (message: string, meta?: Record) => void; @@ -462,8 +464,21 @@ export async function ensureDefaultOrganization( // then — the window is a WAIT, not a silent mis-binding. const legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()); if (!adminUserId && !legacyGrantAnchorRetired) { - const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1); - if (adminPs.length === 0 || !adminPs[0].id) { + // [ADR-0131 D3/D4] The set confers platform standing only while the + // security catalog holds it and the activation ledger leaves it on — the + // derivation's own reading, never a `sys_permission_set` row. A read that + // fails decides nothing, as `tryFind` does: `no_admin`. + let adminSetInEffect = false; + try { + adminSetInEffect = await permissionSetInEffect( + ql, + (object, query) => ql.find(object, query, { context: SYSTEM_CTX }), + ADMIN_FULL_ACCESS, + ); + } catch { + adminSetInEffect = false; + } + if (!adminSetInEffect) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } // [ADR-0131 D4] The grants NAMING the set (`permission_set`), not the diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index f1a1bd99989..a48852b3f98 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -564,10 +564,10 @@ describe('#15597 — settleSelfRegistrationGrant refuses on a malformed row inst expect(lines.join('\n')).toContain('no usable id'); }); - it('a DEACTIVATED row is still an ordinary non-resolution, not a malformed-row refusal', async () => { - // The `active !== false` clause is a selection predicate and stays one: - // deactivating a set must keep reporting "does not resolve", not start - // reporting a malformed row. This is the boundary between the two clauses. + it('[ADR-0131 D3] the row\'s `active` no longer selects — the row carries the id, the ledger the switch', async () => { + // Whether the set grants was asked at admission, of the catalog and the + // activation ledger; the resolver reads no catalog row, so a row flag is + // not a deactivation and the settlement does not read it. const engine = await bootEngine(); await engine.insert('sys_user', USER, SYSTEM); await engine.insert('sys_permission_set', { id: 'ps_off', name: 'portal_user', label: 'Portal', active: false }, SYSTEM); @@ -575,9 +575,9 @@ describe('#15597 — settleSelfRegistrationGrant refuses on a malformed row inst const lines: string[] = []; await settle(engine, 'portal_user', lines); - expect(await grants(engine)).toEqual([]); - const report = lines.join('\n'); - expect(report).toContain("no active sys_permission_set row named 'portal_user'"); - expect(report).not.toContain('no usable id'); + const rows = await grants(engine); + expect(rows.length).toBe(1); + expect((rows[0] as { permission_set_id: string }).permission_set_id).toBe('ps_off'); + expect(lines.join('\n')).not.toContain('no usable id'); }); }); diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index dfbca615362..d0c04e5c968 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -36,6 +36,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { SecurityPlugin, bootstrapPlatformAdmin, reconcileOrgAdminGrant } from '@objectstack/plugin-security'; import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; @@ -91,8 +92,15 @@ async function standingByReader(posture: Posture): Promise { manifest = m; } }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [...((manifest.permissions as unknown[]) ?? [])], + // [ADR-0131 D3/D4] The bootstrap sets the manifest ships are the + // catalog's `permission` definitions — what the security catalog the + // plugin binds at `start()` resolves a grant's set name to. + get: async (type: string, name: string) => + type === 'permission' + ? ((manifest.permissions as any[]) ?? []).find((p) => p?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async (type?: string) => + type === undefined || type === 'permission' ? [...((manifest.permissions as unknown[]) ?? [])] : [], }, ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } @@ -118,7 +126,8 @@ async function standingByReader(posture: Posture): Promise engine.delete('sys_user_permission_set', { where: { id: adminGrant.id }, ...sys })) : 'no-grant-row'; - const [adminSet] = (await engine.find('sys_permission_set', { - where: { name: 'admin_full_access', organization_id: null }, context: SYS, - })) as any[]; - guard.deactivateAdministratorSet = await verdict(() => - engine.update('sys_permission_set', { id: adminSet.id, active: false }, sys)); + // [ADR-0131 D3, ADR-0126 §4] Deactivation is the activation ledger's row, + // not the set row's `active` (which the resolver no longer reads). + guard.deactivateAdministratorSet = await verdict(() => engine.insert( + 'sys_metadata_activation', + { id: 'act_admin', metadata_type: 'permission', name: 'admin_full_access', package_id: 'com.objectstack.plugin-security', active: false }, + sys, + )); guard.banSoleAdministrator = await verdict(() => engine.update('sys_user', { id: 'usr_admin', banned: true }, sys)); return { diff --git a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts index 5e349437166..e97382340a4 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts @@ -76,8 +76,15 @@ async function soleGrantAnchoredAdmin(): Promise { manifest: { register: (m: any) => { manifest = m; } }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [...((manifest.permissions as unknown[]) ?? [])], + // [ADR-0131 D3/D4] The bootstrap sets the manifest ships are the + // catalog's `permission` definitions — what the security catalog the + // plugin binds at `start()` resolves a grant's set name to. + get: async (type: string, name: string) => + type === 'permission' + ? ((manifest.permissions as any[]) ?? []).find((p) => p?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async (type?: string) => + type === undefined || type === 'permission' ? [...((manifest.permissions as unknown[]) ?? [])] : [], }, }; const ctx: any = { diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts index 0d01ac9ae09..46210f01378 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts @@ -1,63 +1,241 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D3, ADR-0126 §4] The last-administrator guard's ledger half. - * - * The authorization resolver reads a permission set's deactivation from the - * activation ledger (`sys_metadata_activation`), not from the set row, so a - * ledger row switching `admin_full_access` off un-makes every grant-derived - * platform administrator at once. `refuseLedgerSwitchingAdminOff` refuses that - * write and passes every other one. + * [ADR-0131 D3/D4, ADR-0126 §4] The last-administrator guard reads the two + * facts the resolver derives a grant-anchored platform administrator from, in + * the two places the resolver reads them: the `admin_full_access` DEFINITION + * in the security catalog bound to the engine, and its SWITCH in the + * activation ledger (`sys_metadata_activation`). A ledger write that would + * switch the set off is judged like every other standing write — refused only + * when it leaves nobody who can sign in (the both-ways pins live in + * `last-admin-guard.re-pricing.test.ts`); these are the ledger half's own + * edges, on a real ObjectQL engine over better-sqlite3 `:memory:`, so the + * engine dispatches the hooks and the store decides how booleans come back. */ -import { describe, it, expect } from 'vitest'; -import { refuseLedgerSwitchingAdminOff } from './last-admin-guard.js'; - -const engineWith = (rows: Array>) => { - const reads: unknown[] = []; - return { - reads, - async find(_object: string, query: any) { - reads.push(query?.where); - const where: Record = query?.where ?? {}; - const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { - if (k.startsWith('$') || (v !== null && typeof v === 'object')) throw new Error(`fake engine: unsupported where '${k}'`); - return r[k] === v; - })); - return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const SYSTEM = { context: { isSystem: true } } as const; +const text = { type: 'text' as const }; + +const objects = [ + { + name: 'sys_user', + label: 'User', + managedBy: 'better-auth', + fields: { + id: { ...text, name: 'id', primaryKey: true }, + name: { ...text, name: 'name' }, + email: { ...text, name: 'email' }, + banned: { name: 'banned', type: 'boolean' as const, readonly: true }, + }, + }, + { + name: 'sys_member', + label: 'Member', + managedBy: 'better-auth', + fields: { + id: { ...text, name: 'id', primaryKey: true }, + user_id: { ...text, name: 'user_id' }, + organization_id: { ...text, name: 'organization_id' }, + role: { ...text, name: 'role' }, }, - }; -}; + }, + { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { ...text, name: 'id', primaryKey: true }, + user_id: { ...text, name: 'user_id' }, + permission_set_id: { ...text, name: 'permission_set_id' }, + permission_set: { ...text, name: 'permission_set' }, + organization_id: { ...text, name: 'organization_id' }, + valid_from: { name: 'valid_from', type: 'datetime' as const }, + valid_until: { name: 'valid_until', type: 'datetime' as const }, + }, + }, + { + name: 'sys_metadata_activation', + label: 'Metadata Activation', + fields: { + id: { ...text, name: 'id', primaryKey: true }, + metadata_type: { ...text, name: 'metadata_type' }, + name: { ...text, name: 'name' }, + package_id: { ...text, name: 'package_id' }, + active: { name: 'active', type: 'boolean' as const }, + }, + }, +]; + +let engines: ObjectQL[] = []; +let ambient: Record = {}; +const ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL']; + +beforeEach(() => { + ambient = Object.fromEntries(ENV.map((k) => [k, process.env[k]])); + process.env.OS_TENANCY_POSTURE = 'single'; + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_PLATFORM_OWNER_EMAIL; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); + const open = engines; + engines = []; + for (const e of open) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +interface Boot { + /** The catalog's permission definitions; `undefined` binds no catalog at all. */ + permissions?: string[]; + /** Wrap the engine the GUARD reads through (hooks stay on the real one). */ + readThrough?: (engine: ObjectQL) => LastAdminGuardEngine; +} + +async function boot(opts: Boot = { permissions: [ADMIN_FULL_ACCESS] }): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o as never); + await engine.syncSchemas(); + const guardEngine = opts.readThrough?.(engine) ?? (engine as unknown as LastAdminGuardEngine); + if (opts.permissions) { + const catalog = { permissions: opts.permissions.map((name) => ({ name })) }; + bindTestSecurityCatalog(engine, catalog); + bindTestSecurityCatalog(guardEngine, catalog); + } + registerLastAdminGuard(guardEngine, { packageId: 'test.last-admin-guard-ledger' }); + return engine; +} + +async function seedGrantAdmin(engine: ObjectQL, id = 'usr_admin'): Promise { + await engine.insert('sys_user', { id, name: id, email: `${id}@corp.example`, banned: false }, SYSTEM); + await engine.insert( + 'sys_user_permission_set', + { id: `ups_${id}`, user_id: id, permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS }, + SYSTEM, + ); +} + +const ledgerRow = (id: string, metadataType: string, name: string, active: unknown) => + ({ id, metadata_type: metadataType, name, package_id: 'pkg', active }); + +const readLedger = async (engine: ObjectQL, id: string) => + (await engine.findOne('sys_metadata_activation', { where: { id } }, SYSTEM)) as Record | null; -describe('[ADR-0131 D3] refuseLedgerSwitchingAdminOff', () => { - it('refuses an insert switching admin_full_access off, the 0 spelling included', async () => { +describe('[ADR-0131 D3] the ledger half — what a ledger write can and cannot take away', () => { + it('refuses switching the last administrator\'s set off — the driver 0 spelling included', async () => { for (const active of [false, 0]) { + const engine = await boot(); + await seedGrantAdmin(engine); await expect( - refuseLedgerSwitchingAdminOff(engineWith([]), { metadata_type: 'permission', name: 'admin_full_access', active }, undefined), + engine.insert('sys_metadata_activation', ledgerRow('act_admin', 'permission', ADMIN_FULL_ACCESS, active), SYSTEM), ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: 'sys_metadata_activation' }); + expect(await readLedger(engine, 'act_admin')).toBeFalsy(); } }); - it('refuses an update by id whose stored row is admin_full_access', async () => { - const engine = engineWith([{ id: 'act_1', metadata_type: 'permission', name: 'admin_full_access', active: true }]); - await expect(refuseLedgerSwitchingAdminOff(engine, { active: false }, 'act_1')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); - expect(engine.reads).toEqual([{ id: 'act_1' }]); + it('CONTROL — another set, a position or a flow of the same name switched off, or the set switched ON, all land', async () => { + const engine = await boot(); + await seedGrantAdmin(engine); + const rows = [ + ledgerRow('act_other', 'permission', 'crm_full', false), + ledgerRow('act_position', 'position', ADMIN_FULL_ACCESS, false), + ledgerRow('act_flow', 'flow', ADMIN_FULL_ACCESS, false), + ledgerRow('act_on', 'permission', ADMIN_FULL_ACCESS, true), + ]; + for (const row of rows) { + await expect(engine.insert('sys_metadata_activation', row, SYSTEM)).resolves.toBeDefined(); + } + for (const row of rows) expect(await readLedger(engine, row.id)).toBeTruthy(); }); - it('refuses a switch-off whose target row cannot be read — fail closed', async () => { - await expect(refuseLedgerSwitchingAdminOff(engineWith([]), { active: false }, 'act_gone')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + it('a second administrator left standing permits the switch-off — the flat refusal is gone', async () => { + const engine = await boot(); + await seedGrantAdmin(engine); + await engine.insert('sys_user', { id: 'usr_owner', name: 'usr_owner', email: 'o@corp.example', banned: false }, SYSTEM); + await engine.insert('sys_member', { id: 'mem_owner', user_id: 'usr_owner', organization_id: 'org_1', role: 'owner' }, SYSTEM); + + await expect( + engine.insert('sys_metadata_activation', ledgerRow('act_admin', 'permission', ADMIN_FULL_ACCESS, false), SYSTEM), + ).resolves.toBeDefined(); + expect((await readLedger(engine, 'act_admin'))?.active).toBeFalsy(); }); - it('CONTROL — another set, a position, a flow, or switching admin_full_access ON pass with no read', async () => { - const engine = engineWith([]); - for (const data of [ - { metadata_type: 'permission', name: 'crm_full', active: false }, - { metadata_type: 'position', name: 'admin_full_access', active: false }, - { metadata_type: 'flow', name: 'admin_full_access', active: false }, - { metadata_type: 'permission', name: 'admin_full_access', active: true }, - ]) { - await expect(refuseLedgerSwitchingAdminOff(engine, data, undefined)).resolves.toBeUndefined(); - } - expect(engine.reads).toEqual([]); + it('a ledger read that fails refuses the write it was asked about — fail closed', async () => { + const engine = await boot({ + permissions: [ADMIN_FULL_ACCESS], + readThrough: (real) => ({ + registerHook: (event, handler, options) => real.registerHook(event, handler, options), + find: async (object, query, options) => { + if (object === 'sys_metadata_activation') throw new Error('the ledger is unreachable'); + return real.find(object, query as never, options as never) as never; + }, + }), + }); + await seedGrantAdmin(engine); + await seedGrantAdmin(engine, 'usr_second'); + // Two administrators — this ban WOULD be legal. It is refused anyway. + await expect(engine.update('sys_user', { id: 'usr_second', banned: true }, SYSTEM)) + .rejects.toThrow(/the ledger is unreachable/); + }); +}); + +describe('[ADR-0131 D3/D4] the emptied-not-fresh diagnosis reads the catalog and the ledger', () => { + it('a switched-off set is an EMPTIED environment: a ban is refused, naming the switch', async () => { + const engine = await boot(); + // Switched off before any grant names it: the guard reads a fresh environment then. + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', 'permission', ADMIN_FULL_ACCESS, false), SYSTEM); + await seedGrantAdmin(engine); + + await expect(engine.update('sys_user', { id: 'usr_admin', banned: true }, SYSTEM)) + .rejects.toThrow(/switched OFF in 'sys_metadata_activation'/); + }); + + it('…and switching it back on is the way out: the restoring write lands', async () => { + const engine = await boot(); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', 'permission', ADMIN_FULL_ACCESS, false), SYSTEM); + await seedGrantAdmin(engine); + + await expect(engine.update('sys_metadata_activation', { id: 'act_admin', active: true }, SYSTEM)).resolves.toBeDefined(); + expect((await readLedger(engine, 'act_admin'))?.active).toBeTruthy(); + }); + + it('a grant naming a set the catalog does not hold is DANGLING: the ban is refused, not waved through', async () => { + const engine = await boot({ permissions: ['member_default'] }); + await seedGrantAdmin(engine); + await expect(engine.update('sys_user', { id: 'usr_admin', banned: true }, SYSTEM)) + .rejects.toThrow(/security catalog does not hold/); + }); + + it('an engine with NO catalog bound counts no grant-anchored administrator — as the resolver grants none', async () => { + const engine = await boot({}); + await seedGrantAdmin(engine); + await expect(engine.update('sys_user', { id: 'usr_admin', banned: true }, SYSTEM)) + .rejects.toThrow(/security catalog does not hold/); + }); + + it('CONTROL — a genuinely fresh environment is still the bootstrap window', async () => { + const engine = await boot(); + await engine.insert('sys_user', { id: 'usr_first', name: 'usr_first', email: 'f@corp.example', banned: false }, SYSTEM); + await expect(engine.update('sys_user', { id: 'usr_first', banned: true }, SYSTEM)).resolves.toBeDefined(); }); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts index a0c83efa15a..982040e52b4 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -51,6 +51,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, USER_STANDING_KEYS, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** @@ -105,6 +106,28 @@ const sysPermissionSet = { }, }; +/** [ADR-0131 D3, ADR-0126 §4] The activation ledger a permission set is switched off in. */ +const sysMetadataActivation = { + name: 'sys_metadata_activation', + label: 'Metadata Activation', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + metadata_type: { name: 'metadata_type', type: 'text' as const }, + name: { name: 'name', type: 'text' as const }, + package_id: { name: 'package_id', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; + +/** Switch `admin_full_access` off in the ledger — before any grant names it, so the guard reads a fresh environment. */ +async function switchAdminSetOff(engine: ObjectQL): Promise { + await engine.insert( + 'sys_metadata_activation', + { id: 'act_admin', metadata_type: 'permission', name: ADMIN_FULL_ACCESS, package_id: 'pkg', active: false }, + SYSTEM, + ); +} + const sysUserPermissionSet = { name: 'sys_user_permission_set', label: 'User Permission Set', @@ -177,10 +200,12 @@ async function boot(opts: { unguarded?: boolean } = {}): Promise { true, ); await engine.init(); - for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet]) { + for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { engine.registry.registerObject(o as never); } await engine.syncSchemas(); + // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); if (!opts.unguarded) { registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard' }); } @@ -508,8 +533,8 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ }); it('the emptied-not-fresh refusal names the remedy that WORKS under a wall', async () => { - // [#11663 L5] The refusal itself is unchanged — a deactivated - // `admin_full_access` row with stranded unscoped grants is still the + // [#11663 L5] The refusal itself is unchanged — a switched-off + // `admin_full_access` with stranded unscoped grants is still the // evidence, and the write is still refused. What changed is that 「restore // the row」 stopped ending the emptiness on a walled rig, so the message // names the channel that does. @@ -517,7 +542,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ declare(undefined); const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM); + await switchAdminSetOff(engine); await engine.insert( 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, @@ -536,7 +561,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ declare(undefined); const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM); + await switchAdminSetOff(engine); await engine.insert( 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, @@ -551,7 +576,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ } // The refusal really fired — without this the absence below would be the // absence of any message at all. - expect(message).toMatch(/DEACTIVATED/); + expect(message).toMatch(/switched OFF/); expect(message).not.toMatch(/OS_PLATFORM_OWNER_EMAIL/); }); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts index 12e99a4696e..f79c3f47a70 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts @@ -10,12 +10,12 @@ * teaching the enumeration the config anchor (#11663 L2) re-priced the * refusals mechanically. Two directions, both load-bearing: * - * - **OBSOLETE where the config anchor stands.** Deleting, renaming or - * deactivating the `admin_full_access` `sys_permission_set` row — write - * shape (4), which used to un-make every platform admin in one write — no - * longer empties a population that contains a config-anchored - * administrator, so those writes are PERMITTED there. Same for deleting - * the legacy grant row itself. + * - **OBSOLETE where the config anchor stands.** Switching `admin_full_access` + * off in the activation ledger — write shape (4), which un-makes every + * grant-anchored platform admin in one write (ADR-0131 D3) — no longer + * empties a population that contains a config-anchored administrator, so + * those writes are PERMITTED there. Same for deleting the legacy grant row + * itself. * - **KEPT where the grant anchor is load-bearing.** With no declared * administrators (`single` posture under Choice 4A, and P5's honoured * legacy window) the identical writes still take the last administrator @@ -39,6 +39,20 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +/** [ADR-0131 D3, ADR-0126 §4] The activation ledger a permission set is switched off in. */ +const sysMetadataActivation = { + name: 'sys_metadata_activation', + label: 'Metadata Activation', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + metadata_type: { name: 'metadata_type', type: 'text' as const }, + name: { name: 'name', type: 'text' as const }, + package_id: { name: 'package_id', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; const ENV = 'OS_PLATFORM_OWNER_EMAIL'; const SYSTEM = { context: { isSystem: true } } as const; @@ -154,17 +168,19 @@ async function boot(): Promise { true, ); await engine.init(); - for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet]) { + for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { engine.registry.registerObject(o as never); } await engine.syncSchemas(); + // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-re-pricing' }); return engine; } /** - * A grant-anchored administrator: verified user + active `admin_full_access` - * row + an unscoped in-window grant. The pre-re-anchor shape of "the last + * A grant-anchored administrator: verified user + the catalog's + * `admin_full_access` (bound in `boot`) + an unscoped in-window grant. The pre-re-anchor shape of "the last * platform admin". */ async function seedGrantAdmin(engine: ObjectQL, userId = 'usr_grant'): Promise { @@ -194,39 +210,40 @@ async function findOne(engine: ObjectQL, object: string, id: string): Promise { - it('DELETING the admin_full_access row is permitted, and lands', async () => { - declare(OWNER); - const engine = await boot(); - await seedConfigAdmin(engine); - await seedGrantAdmin(engine); // the row also carries a live grant — still not the last anchor +/** The ledger row switching `admin_full_access` off — an insert. */ +const switchOff = (engine: ObjectQL) => engine.insert( + 'sys_metadata_activation', + { id: 'act_admin', metadata_type: 'permission', name: ADMIN_FULL_ACCESS, package_id: 'pkg', active: false }, + SYSTEM, +); - await expect( - engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), - ).resolves.toBeDefined(); - expect(await findOne(engine, 'sys_permission_set', 'ps_admin')).toBeFalsy(); - }); +/** A ledger row that leaves the set on, for the update spellings to move. */ +const seedLedgerRow = (engine: ObjectQL, id: string, name: string) => engine.insert( + 'sys_metadata_activation', + { id, metadata_type: 'permission', name, package_id: 'pkg', active: true }, + SYSTEM, +); - it('DEACTIVATING it (ADR-0049 spelling) is permitted, and lands', async () => { +describe('[#11973] OBSOLETE refusals — shape-(4) writes are permitted while a config-anchored administrator stands', () => { + it('SWITCHING admin_full_access OFF (a ledger insert) is permitted, and lands', async () => { declare(OWNER); const engine = await boot(); await seedConfigAdmin(engine); - await seedGrantAdmin(engine); + await seedGrantAdmin(engine); // the set also carries a live grant — still not the last anchor - await engine.update('sys_permission_set', { id: 'ps_admin', active: false }, SYSTEM); - const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { active?: unknown }; - expect(row?.active).toBeFalsy(); + await expect(switchOff(engine)).resolves.toBeDefined(); + expect(((await findOne(engine, 'sys_metadata_activation', 'act_admin')) as { active?: unknown })?.active).toBeFalsy(); }); - it('RENAMING it is permitted, and lands', async () => { + it('switching its existing row off (a ledger update) is permitted, and lands', async () => { declare(OWNER); const engine = await boot(); await seedConfigAdmin(engine); await seedGrantAdmin(engine); + await seedLedgerRow(engine, 'act_admin', ADMIN_FULL_ACCESS); - await engine.update('sys_permission_set', { id: 'ps_admin', name: 'renamed_away' }, SYSTEM); - const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { name?: unknown }; - expect(row?.name).toBe('renamed_away'); + await engine.update('sys_metadata_activation', { id: 'act_admin', active: false }, SYSTEM); + expect(((await findOne(engine, 'sys_metadata_activation', 'act_admin')) as { active?: unknown })?.active).toBeFalsy(); }); it('deleting the LAST legacy grant row is permitted, and lands', async () => { @@ -243,36 +260,36 @@ describe('[#11973] OBSOLETE refusals — shape-(4) writes are permitted while a }); describe('[#11973] KEPT refusals — the same writes still refuse where the grant anchor is load-bearing (Choice 4A / P5)', () => { - it('with NO declared administrators, deleting the admin_full_access row is still refused', async () => { + it('with NO declared administrators, switching admin_full_access off is still refused', async () => { const engine = await boot(); // ENV cleared in beforeEach — the `single` shape await seedGrantAdmin(engine); - await expect( - engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), - ).rejects.toThrow(/administrator/i); - expect(await findOne(engine, 'sys_permission_set', 'ps_admin')).toBeTruthy(); + await expect(switchOff(engine)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await findOne(engine, 'sys_metadata_activation', 'act_admin')).toBeFalsy(); }); - it('…deactivating it is still refused', async () => { + it('…switching its existing row off is still refused', async () => { const engine = await boot(); await seedGrantAdmin(engine); + await seedLedgerRow(engine, 'act_admin', ADMIN_FULL_ACCESS); await expect( - engine.update('sys_permission_set', { id: 'ps_admin', active: false }, SYSTEM), - ).rejects.toThrow(/administrator/i); - const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { active?: unknown }; - expect(row?.active).toBeTruthy(); + engine.update('sys_metadata_activation', { id: 'act_admin', active: false }, SYSTEM), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: 'sys_metadata_activation' }); + expect(((await findOne(engine, 'sys_metadata_activation', 'act_admin')) as { active?: unknown })?.active).toBeTruthy(); }); - it('…renaming it is still refused', async () => { + it('…moving another ledger row ONTO admin_full_access, switched off, is still refused', async () => { + // The row the write addresses names another set before the write; the + // simulation re-tests it as it would read after. const engine = await boot(); await seedGrantAdmin(engine); + await seedLedgerRow(engine, 'act_other', 'crm_full'); await expect( - engine.update('sys_permission_set', { id: 'ps_admin', name: 'renamed_away' }, SYSTEM), - ).rejects.toThrow(/administrator/i); - const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { name?: unknown }; - expect(row?.name).toBe(ADMIN_FULL_ACCESS); + engine.update('sys_metadata_activation', { id: 'act_other', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(((await findOne(engine, 'sys_metadata_activation', 'act_other')) as { name?: unknown })?.name).toBe('crm_full'); }); it('…deleting the last grant row is still refused', async () => { @@ -297,8 +314,26 @@ describe('[#11973] KEPT refusals — the same writes still refuse where the gran ); await seedGrantAdmin(engine); + await expect(switchOff(engine)).rejects.toThrow(/administrator/i); + }); +}); + +describe('[ADR-0131 D3] the sys_permission_set ROW is no standing input — writing it is not judged', () => { + it('with the grant anchor load-bearing, deleting, renaming or deactivating the row still lands', async () => { + // The resolver reads the set from the catalog and its switch from the + // ledger, never the row; so no row write moves an administrator. + const engine = await boot(); + await seedGrantAdmin(engine); + + await engine.update('sys_permission_set', { id: 'ps_admin', active: false }, SYSTEM); + await engine.update('sys_permission_set', { id: 'ps_admin', name: 'renamed_away' }, SYSTEM); await expect( engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), + ).resolves.toBeDefined(); + expect(await findOne(engine, 'sys_permission_set', 'ps_admin')).toBeFalsy(); + // …and the administrator it never anchored is still protected. + await expect( + engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM }), ).rejects.toThrow(/administrator/i); }); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index 74eb6211058..292074250f5 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -3,9 +3,9 @@ /** * [#5892 + #5941 + #5978 + #6084 / cloud ADR-0024 D5.2] The break-glass guard — * every half of ONE invariant: a `banned = true` write, a `sys_user` row DELETE, - * a standing revocation on `sys_member` / `sys_user_permission_set`, and a - * delete-or-rename of the `admin_full_access` `sys_permission_set` row may each - * only proceed while an administrator who can sign in is left behind. + * a standing revocation on `sys_member` / `sys_user_permission_set`, and an + * activation-ledger write switching `admin_full_access` off (ADR-0131 D3) may + * each only proceed while an administrator who can sign in is left behind. * * #6084 adds one thing the earlier three did not need: the guard must still be * ON afterwards. Its write shape empties the administrator population, and the @@ -62,6 +62,7 @@ import { registerIdentityWriteGuard, registerManagedUpdateWhitelist } from './id import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; import { createObjectQLAdapterFactory } from './objectql-adapter.js'; import { buildAdminPluginSchema, buildOrganizationPluginSchema } from './auth-schema-config.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; import { admin } from 'better-auth/plugins/admin'; import { organization } from 'better-auth/plugins/organization'; @@ -133,6 +134,23 @@ const sysAccount = { }, }; +/** + * [ADR-0131 D3, ADR-0126 §4] The activation ledger — where the resolver reads + * whether `admin_full_access` is switched off. `active` is `boolean`, so on + * this real sqlite database it stores as 0/1. + */ +const sysMetadataActivation = { + name: 'sys_metadata_activation', + label: 'Metadata Activation', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + metadata_type: { name: 'metadata_type', type: 'text' as const }, + name: { name: 'name', type: 'text' as const }, + package_id: { name: 'package_id', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; + const sysUserPermissionSet = { name: 'sys_user_permission_set', label: 'User Permission Set', @@ -195,6 +213,13 @@ interface BootOptions { let engines: ObjectQL[] = []; +/** + * [ADR-0131 D3/D4] Each engine's security catalog — the permission names it + * holds, read afresh per lookup, so a case can declare or remove a set. + */ +const catalogs = new WeakMap>(); +const catalogOf = (engine: ObjectQL): Set => catalogs.get(engine)!; + /** * [#11663 L5] The tenancy posture is PINNED to `single` for this whole file, and * it is pinned rather than inherited because the answer now depends on it: the @@ -242,17 +267,23 @@ async function boot(opts: BootOptions = {}): Promise { true, ); await engine.init(); - for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysAccount]) { + for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysAccount, sysMetadataActivation]) { engine.registry.registerObject(o as never); } await engine.syncSchemas(); + const names = new Set(); + catalogs.set(engine, names); + const catalog = () => ({ permissions: [...names].map((name) => ({ name })) }); + bindTestSecurityCatalog(engine, catalog); if (opts.withIdentityWriteGuard) { registerManagedUpdateWhitelist('sys_user', SYS_USER_PROFILE_EDIT_FIELDS); registerIdentityWriteGuard(engine, { packageId: 'test.identity-write-guard' }); } if (!opts.unguarded) { - registerLastAdminGuard(opts.readThrough?.(engine) ?? (engine as unknown as LastAdminGuardEngine), { + const guardEngine = opts.readThrough?.(engine) ?? (engine as unknown as LastAdminGuardEngine); + bindTestSecurityCatalog(guardEngine, catalog); + registerLastAdminGuard(guardEngine, { packageId: 'test.last-admin-guard', ...(opts.maxScan !== undefined ? { maxScan: opts.maxScan } : {}), }); @@ -299,7 +330,13 @@ async function seedUser( } } +/** + * [ADR-0131 D3/D4] Declare the two sets in the catalog — what a grant's name + * resolves to. The rows are written too, as the platform still writes them; + * the guard reads none of them. + */ async function seedAdminPermissionSet(engine: ObjectQL): Promise { + catalogOf(engine).add(ADMIN_FULL_ACCESS).add('member_default'); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }, SYSTEM); await engine.insert('sys_permission_set', { id: 'ps_member', name: 'member_default' }, SYSTEM); } @@ -1640,31 +1677,39 @@ describe('[#5978] reverse verification: without the guard, the third path locks }); // --------------------------------------------------------------------------- -// [#6084] The FOURTH write shape — the one table that is not an identity table +// [#6084, ADR-0131 D3] The FOURTH write shape — switching the set off // -// "Who is a platform admin" is resolved BY NAME: `resolveAdminUserIds` looks -// the permission set up as `where: { name: 'admin_full_access' }` and only then -// reads the grants pointing at its id. So the row named `admin_full_access` is -// itself part of the GRANT-anchored administrator evidence, and deleting it — -// or calling it something else — un-makes every grant-anchored platform admin -// in one write while `sys_user`, `sys_member` and `sys_user_permission_set` all -// stay exactly as they were. +// "Who is a platform admin" is resolved BY NAME: a grant names +// `admin_full_access`, the set is the security catalog's definition of that +// name, and whether it is in effect is the activation ledger's answer. So ONE +// ledger write switching it off un-makes every grant-anchored platform admin +// while `sys_user`, `sys_member` and `sys_user_permission_set` all stay exactly +// as they were. // -// ⚠️ Not "every platform admin": since the #11663 re-anchor (L2) standing has a -// SECOND anchor this write cannot reach — a config-anchored administrator (a -// declared `OS_PLATFORM_OWNER_EMAIL` address on a VERIFIED `sys_user` row) is -// derived at `resolve-authz-context.ts` §6b-config without reading this row at -// all, and `resolveAdminUserIds` counts it through the resolver's own -// predicate. Where one stands these refusals are re-priced away and the write -// is PERMITTED (pinned in `last-admin-guard.re-pricing.test.ts`). This file -// declares no owner emails, so the grant anchor is the whole population here -// and the refusals below are the live price. +// ⚠️ Not "every platform admin": a config-anchored administrator (#11663 L2) +// survives it, and where one stands the write is PERMITTED (pinned in +// `last-admin-guard.re-pricing.test.ts`). This file declares no owner emails, +// so the grant anchor is the whole population here and the refusals below are +// the live price. // // The block pins the same five things each earlier path did, plus the one this // path adds: the guard must NOT go quiet on every other path afterwards. // --------------------------------------------------------------------------- -describe('[#6084] path 4 — deleting or renaming the admin_full_access permission-set row', () => { +/** A ledger row, as the ADR-0126 doors write it. */ +const ledgerRow = (id: string, name: string, active: unknown, metadataType = 'permission') => + ({ id, metadata_type: metadataType, name, package_id: 'com.objectstack.plugin-security', active }); + +/** What a disable door writes for a set with no row yet: an insert switched off. */ +const switchOff = (engine: ObjectQL, name: string = ADMIN_FULL_ACCESS) => + engine.insert('sys_metadata_activation', ledgerRow(`act_${name}`, name, false), SYSTEM); + +async function ledgerActive(engine: ObjectQL, id: string): Promise { + const row = await engine.findOne('sys_metadata_activation', { where: { id } }, SYSTEM); + return row ? row.active : undefined; +} + +describe('[#6084, ADR-0131 D3] path 4 — switching admin_full_access off in the activation ledger', () => { let engine: ObjectQL; beforeEach(async () => { @@ -1672,45 +1717,31 @@ describe('[#6084] path 4 — deleting or renaming the admin_full_access permissi await seedAdminPermissionSet(engine); }); - /** What a metadata delete (`retirePermissionSetRecord`) ultimately writes. */ - const deleteSet = (id: string) => - engine.delete('sys_permission_set', { where: { id }, ...SYSTEM }); - const renameSet = (id: string, name: string) => - engine.update('sys_permission_set', { id, name }, SYSTEM); - - const setName = async (id: string): Promise => { - const row = await engine.findOne('sys_permission_set', { where: { id } }, SYSTEM); - return row?.name; - }; - - it('THE REPRODUCTION from the issue: deleting the row is refused and the row survives', async () => { + it('THE REPRODUCTION: switching it off is refused and nothing lands', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await expect(deleteSet(PS_ADMIN)).rejects.toMatchObject({ + await expect(switchOff(engine)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, - object: 'sys_permission_set', + object: 'sys_metadata_activation', }); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(true); + expect(await ledgerActive(engine, `act_${ADMIN_FULL_ACCESS}`)).toBeUndefined(); }); - it('RENAMING it is refused too — the row would survive, the standing would not', async () => { + it('switching its EXISTING row off is refused too, and the row stays on', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, true), SYSTEM); - await expect(renameSet(PS_ADMIN, 'admin_full_access_old')).rejects.toThrow( - /last administrator/i, - ); - // Nothing was written: the enumeration still finds the row by its name. - expect(await setName(PS_ADMIN)).toBe(ADMIN_FULL_ACCESS); + await expect( + engine.update('sys_metadata_activation', { id: 'act_admin', active: false }, SYSTEM), + ).rejects.toThrow(/last administrator/i); + expect(await ledgerActive(engine, 'act_admin')).toBeTruthy(); }); it('THE PATH ITSELF: no identity table is touched, which is why the first three halves miss it', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/ADR-0135 D5\.2/); - // The user row is present and unbanned (#5892 / #5941 see nothing), and the - // grant row is untouched as well (#5978 sees nothing) — the write lands on - // a fourth table entirely, and is still refused. + await expect(switchOff(engine)).rejects.toThrow(/ADR-0135 D5\.2/); await expectUserRowUntouched(engine, 'usr_platform'); expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true); }); @@ -1718,123 +1749,114 @@ describe('[#6084] path 4 — deleting or renaming the admin_full_access permissi it('the refusal explains itself: who loses standing, which table, why, and the fix', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/Refusing this permission-set removal/); - // The USER about to be locked out is named, not the permission-set row id. - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/'usr_platform'/); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/last administrator/i); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/sys_permission_set/); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/ADR-0135 D5\.2/); - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(new RegExp(ADMIN_FULL_ACCESS)); - // …and the closing advice names the doors that actually write this table. - await expect(deleteSet(PS_ADMIN)).rejects.toThrow(/package uninstall/); - - // NOT the SCIM sentence the other standing halves end with: nothing in an - // IdP writes `sys_permission_set`, so pointing this operator at a group - // mapping would send them into a system they may not even run. let message = ''; try { - await deleteSet(PS_ADMIN); + await switchOff(engine); } catch (e) { message = (e as Error).message; } + expect(message).toMatch(/Refusing this activation-ledger write/); + // The USER about to be locked out is named, not the ledger row id. + expect(message).toMatch(/'usr_platform'/); + expect(message).toMatch(/last administrator/i); + expect(message).toMatch(/sys_metadata_activation/); + expect(message).toMatch(/ADR-0135 D5\.2/); + expect(message).toMatch(new RegExp(ADMIN_FULL_ACCESS)); + // …and the closing advice names the doors that actually write this table, + // NOT the SCIM sentence: nothing in an IdP writes the ledger. + expect(message).toMatch(/disable action/); expect(message).not.toMatch(/SCIM group mapping/); }); - it('a rename to a DIFFERENT name is what is refused — the payload is simulated, not pattern-matched', async () => { + it('the payload is simulated, not pattern-matched: switching the row ON proceeds, OFF is refused', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, true), SYSTEM); - // Re-writing the same name changes nothing the enumeration reads, so the - // simulation finds the administrator still there and the write proceeds. - await expect(renameSet(PS_ADMIN, ADMIN_FULL_ACCESS)).resolves.toBeTruthy(); - await expect(renameSet(PS_ADMIN, 'something_else')).rejects.toThrow(/last administrator/i); + await expect(engine.update('sys_metadata_activation', { id: 'act_admin', active: true }, SYSTEM)).resolves.toBeTruthy(); + await expect(engine.update('sys_metadata_activation', { id: 'act_admin', active: false }, SYSTEM)) + .rejects.toThrow(/last administrator/i); + }); + + it('moving ANOTHER row onto admin_full_access, switched off, is refused — the target is re-read', async () => { + await seedUser(engine, 'usr_platform', { platformAdmin: true }); + await engine.insert('sys_metadata_activation', ledgerRow('act_member', 'member_default', true), SYSTEM); + + await expect( + engine.update('sys_metadata_activation', { id: 'act_member', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM), + ).rejects.toThrow(/last administrator/i); }); // ── not over-tightened ──────────────────────────────────────────────────── - it('an org admin elsewhere keeps the removal legal — the invariant is the ENVIRONMENT\'s', async () => { + it('an org admin elsewhere keeps the switch-off legal — the invariant is the ENVIRONMENT\'s', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); await seedUser(engine, 'usr_owner', { role: 'owner' }); - // The platform-admin half of the enumeration goes empty and the guard has - // no opinion, because the environment still has an administrator. A rule - // that protected the platform-admin POPULATION rather than the environment - // would have refused this. - await expect(deleteSet(PS_ADMIN)).resolves.toBeDefined(); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(false); + await expect(switchOff(engine)).resolves.toBeDefined(); + expect(await ledgerActive(engine, `act_${ADMIN_FULL_ACCESS}`)).toBeFalsy(); }); - it('a payload that does not touch `name` costs no reads at all', async () => { + it('a payload touching no standing key costs no reads at all', async () => { const quiet = await boot({ readThrough: (real) => ({ registerHook: (event, handler, options) => real.registerHook(event, handler, options), find: async () => { - throw new Error('the guard must not read anything for a non-name payload'); + throw new Error('the guard must not read anything for a non-standing payload'); }, }), }); await seedAdminPermissionSet(quiet); await seedUser(quiet, 'usr_platform', { platformAdmin: true }); + // An inserted row switched ON can take nothing away, so it is not read for either. + await quiet.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, true), SYSTEM); - // Every read this guard makes runs inside the fail-CLOSED envelope, so a - // payload that provoked ANY read here would come back as a refusal. It - // resolving is the proof that PERMISSION_SET_STANDING_KEYS skipped it - // statically — which is the shape of every projection pass, every - // `os meta resync` and every Setup edit of a permission set. + // Every read runs inside the fail-CLOSED envelope, so a payload that + // provoked ANY read would come back as a refusal. await expect( - quiet.update('sys_permission_set', { id: PS_ADMIN, label: 'Full Access (edited)' }, SYSTEM), + quiet.update('sys_metadata_activation', { id: 'act_admin', package_id: 'com.example.other' }, SYSTEM), ).resolves.toBeTruthy(); }); - it('deleting or renaming ANOTHER permission set is unaffected', async () => { + it('switching ANOTHER set off — or a position of the same name — is unaffected', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await expect(renameSet('ps_member', 'member_default_v2')).resolves.toBeTruthy(); - await expect(deleteSet('ps_member')).resolves.toBeDefined(); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(true); + await expect(switchOff(engine, 'member_default')).resolves.toBeDefined(); + await expect( + engine.insert('sys_metadata_activation', ledgerRow('act_pos', ADMIN_FULL_ACCESS, false, 'position'), SYSTEM), + ).resolves.toBeDefined(); }); it('an ALREADY-banned platform admin is not protected — nothing is being taken away', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true, banned: true }); - await expect(deleteSet(PS_ADMIN)).resolves.toBeDefined(); + await expect(switchOff(engine)).resolves.toBeDefined(); }); - it('an ORG-SCOPED grant holder never was a break-glass admin, so the row stays removable', async () => { + it('an ORG-SCOPED grant holder never was a break-glass admin, so the set can be switched off', async () => { await seedUser(engine, 'usr_scoped', { grant: { organization_id: ORG } }); - await expect(deleteSet(PS_ADMIN)).resolves.toBeDefined(); + await expect(switchOff(engine)).resolves.toBeDefined(); }); // ── predicate / bulk, and fail-closed ───────────────────────────────────── - it('an unpredicated `multi` delete — the one that empties the table — is refused', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(engine.delete('sys_permission_set', { multi: true, ...SYSTEM })).rejects.toThrow( - /last administrator/i, - ); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(true); - }); - - it('a predicate rename that sweeps every permission set at once is refused', async () => { + it('a predicate write that switches every row off at once is refused', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, true), SYSTEM); + await engine.insert('sys_metadata_activation', ledgerRow('act_member', 'member_default', true), SYSTEM); await expect( - engine.update( - 'sys_permission_set', - { name: 'retired' }, - { multi: true, where: { id: { $in: [PS_ADMIN, 'ps_member'] } }, ...SYSTEM }, - ), + engine.update('sys_metadata_activation', { active: false }, { multi: true, where: { metadata_type: 'permission' }, ...SYSTEM }), ).rejects.toThrow(/last administrator/i); - expect(await setName(PS_ADMIN)).toBe(ADMIN_FULL_ACCESS); + expect(await ledgerActive(engine, 'act_admin')).toBeTruthy(); }); - it('fails CLOSED: an unreadable table refuses a removal that would have been legal', async () => { + it('fails CLOSED: an unreadable ledger refuses a switch-off that would have been legal', async () => { const broken = await boot({ readThrough: (real) => ({ registerHook: (event, handler, options) => real.registerHook(event, handler, options), find: async () => { - throw new Error('sys_permission_set is unreadable'); + throw new Error('sys_metadata_activation is unreadable'); }, }), }); @@ -1842,14 +1864,10 @@ describe('[#6084] path 4 — deleting or renaming the admin_full_access permissi await seedUser(broken, 'usr_platform', { platformAdmin: true }); await seedUser(broken, 'usr_owner', { role: 'owner' }); - // Two administrators exist — this removal WOULD be legal. Refused anyway. - await expect( - broken.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).rejects.toThrow(/Refusing this permission-set removal/); - await expect( - broken.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).rejects.toThrow(/sys_permission_set is unreadable/); - expect(await rowExists(broken, 'sys_permission_set', PS_ADMIN)).toBe(true); + // Two administrators exist — this switch-off WOULD be legal. Refused anyway. + await expect(switchOff(broken)).rejects.toThrow(/Refusing this activation-ledger write/); + await expect(switchOff(broken)).rejects.toThrow(/sys_metadata_activation is unreadable/); + expect(await ledgerActive(broken, `act_${ADMIN_FULL_ACCESS}`)).toBeUndefined(); }); it('a population larger than the guard can enumerate refuses, in the op\'s own words', async () => { @@ -1858,10 +1876,33 @@ describe('[#6084] path 4 — deleting or renaming the admin_full_access permissi await seedUser(tiny, 'usr_p1', { platformAdmin: true }); await seedUser(tiny, 'usr_p2', { platformAdmin: true }); - await expect( - tiny.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).rejects.toThrow(/Remove a narrower set of permission sets/); - expect(await rowExists(tiny, 'sys_permission_set', PS_ADMIN)).toBe(true); + await expect(switchOff(tiny)).rejects.toThrow(/Switch off a narrower set of catalog items/); + expect(await ledgerActive(tiny, `act_${ADMIN_FULL_ACCESS}`)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// [ADR-0131 D3/D4] The `sys_permission_set` ROW is no standing input +// +// The resolver reads the set from the catalog and its switch from the ledger, +// and no `sys_permission_set` row at all — so deleting, renaming, deactivating +// or re-scoping the row moves no administrator, and the guard judges none of +// those writes (they used to be shape (4)'s spellings). +// --------------------------------------------------------------------------- + +describe('[ADR-0131 D3/D4] the sys_permission_set row is no standing input', () => { + it('the last administrator\'s set ROW can be deactivated, renamed, re-scoped and deleted — and the admin is still protected', async () => { + const engine = await boot(); + await seedAdminPermissionSet(engine); + await seedUser(engine, 'usr_platform', { platformAdmin: true }); + + await expect(engine.update('sys_permission_set', { id: PS_ADMIN, active: false }, SYSTEM)).resolves.toBeTruthy(); + await expect(engine.update('sys_permission_set', { id: PS_ADMIN, name: 'renamed_away' }, SYSTEM)).resolves.toBeTruthy(); + await expect(engine.delete('sys_permission_set', { multi: true, ...SYSTEM })).resolves.toBeDefined(); + expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(false); + + // The standing never rode on the row: the ban is still the last-admin refusal. + await expect(ban(engine, 'usr_platform')).rejects.toThrow(/last administrator/i); }); }); @@ -1869,34 +1910,25 @@ describe('[#6084] path 4 — deleting or renaming the admin_full_access permissi // [#6084] The AMPLIFICATION — the half that matters more than the fourth hook // // Both verdicts open with "no administrator here, nothing to protect, proceed". -// An environment whose `admin_full_access` row is gone reads exactly that way, -// so before this change ONE write did not merely lock the environment out — it -// switched the guard off for #5892, #5941 and #5978 as well. +// An environment whose `admin_full_access` definition went away (an +// uninstalled package, a removed environment definition — not a write this +// guard can see) reads exactly that way, so ONE such change would not merely +// lock the environment out — it would switch the guard off for #5892, #5941 +// and #5978 as well. // -// So "zero administrators" is now split into the two states it was conflating: -// a genuinely fresh environment (permitted, unchanged) and one that was emptied -// (refused). The evidence is a DANGLING unscoped in-window grant, which no -// producer can write — every one of them inserts the permission set first and -// reads its id back — so the bootstrap window's behaviour is unchanged by -// construction, and the tests below measure that in both directions. +// So "zero administrators" is split into the two states it was conflating: a +// genuinely fresh environment (permitted, unchanged) and one that was emptied +// (refused). The evidence is a DANGLING unscoped in-window grant — one naming +// a set the catalog does not hold. // --------------------------------------------------------------------------- describe('[#6084] a zero-administrator reading is no longer automatically the bootstrap window', () => { - /** - * The environment the fourth path leaves behind, built the only way it can - * still be reached now that the write itself is guarded: the delete lands - * while the guard is NOT registered — a pre-#6084 deployment, a migration, a - * restore, a direct database edit — and the platform then boots with the - * guard on, which is when `registerLastAdminGuard` runs for real. - */ + /** The environment a removed `admin_full_access` definition leaves behind. */ async function wipedEnvironment(): Promise { - const engine = await boot({ unguarded: true }); + const engine = await boot(); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - await engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }); - registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { - packageId: 'test.last-admin-guard', - }); + catalogOf(engine).delete(ADMIN_FULL_ACCESS); return engine; } @@ -1913,10 +1945,10 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo it('…and the user delete and the grant revoke with it — all three halves stay on', async () => { const engine = await wipedEnvironment(); - await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/); + await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/state a REMOVED 'admin_full_access' permission-set definition leaves behind/); await expect( engine.delete('sys_user_permission_set', { where: { id: 'ups_usr_platform' }, ...SYSTEM }), - ).rejects.toThrow(/state a DELETED 'admin_full_access' permission-set row leaves behind/); + ).rejects.toThrow(/state a REMOVED 'admin_full_access' permission-set definition leaves behind/); expect(await userExists(engine, 'usr_platform')).toBe(true); expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true); }); @@ -1926,10 +1958,9 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo await expect(ban(engine, 'usr_platform')).rejects.toThrow(/recognises NO administrator/); await expect(ban(engine, 'usr_platform')).rejects.toThrow(/not the bootstrap window/i); - // Holder and target are both quoted, so an operator can go find the row. // [ADR-0131 D4] The target is the set the grant NAMES — its reference. - await expect(ban(engine, 'usr_platform')).rejects.toThrow(/'usr_platform'/); await expect(ban(engine, 'usr_platform')).rejects.toThrow(`'usr_platform' → '${ADMIN_FULL_ACCESS}'`); + await expect(ban(engine, 'usr_platform')).rejects.toThrow(/security catalog does not hold/); await expect(ban(engine, 'usr_platform')).rejects.toThrow(/ADR-0135 D5\.2/); }); @@ -1938,14 +1969,10 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_platform', { platformAdmin: true }); - // The write that would have disabled everything is refused… - await expect( - engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).rejects.toThrow(/last administrator/i); + await expect(switchOff(engine)).rejects.toThrow(/last administrator/i); // …and in the SAME environment the three earlier paths still refuse with // the ordinary last-administrator verdict rather than the bootstrap - // exemption — which is what "the fourth write does not disarm the other - // three" means, measured instead of argued. + // exemption. await expect(ban(engine, 'usr_platform')).rejects.toThrow(/last administrator/i); await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/last administrator/i); await expect( @@ -1960,28 +1987,19 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_first', { role: 'member' }); - // No administrator, and no evidence there ever was one: this IS the - // bootstrap window, and it behaves exactly as it did before #6084. await expect( engine.delete('sys_member', { where: { id: 'mem_usr_first' }, ...SYSTEM }), ).resolves.toBeDefined(); await expect(ban(engine, 'usr_first')).resolves.toBeTruthy(); await expect(removeUser(engine, 'usr_first')).resolves.toBeDefined(); - // …including writes to the fourth table: an environment with no - // administrator to lose can still rename and retire the set row. - await expect( - engine.update('sys_permission_set', { id: PS_ADMIN, name: 'admin_full_access_v2' }, SYSTEM), - ).resolves.toBeTruthy(); - await expect( - engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).resolves.toBeDefined(); + // …including the fourth path: with no administrator to lose, the set can + // be switched off. + await expect(switchOff(engine)).resolves.toBeDefined(); }); - it('an unscoped grant whose permission set still EXISTS is not evidence of a wipe', async () => { + it('an unscoped grant whose permission set the catalog still HOLDS is not evidence of a wipe', async () => { const engine = await boot(); await seedAdminPermissionSet(engine); - // An ordinary pre-first-admin state: somebody holds `member_default` - // unscoped, and nobody is an administrator yet. await seedUser(engine, 'usr_a', { grant: { permission_set_id: 'ps_member' } }); await expect(ban(engine, 'usr_a')).resolves.toBeTruthy(); @@ -2010,337 +2028,99 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo }); // --------------------------------------------------------------------------- -// [#6084] Reverse verification — the same fixtures with the guard NOT registered +// [#8613 / ADR-0049, ADR-0131 D3] A SWITCHED-OFF break-glass set is an emptied +// environment, not a fresh one // -// Direction, decided before running: RED, the usual one. Without -// `registerLastAdminGuard` the fourth write succeeds and takes the whole -// platform-admin population with it, and the ban that follows succeeds too — -// the amplification, on the engine as it behaved before this change. -// -// The second half of that pair is the `wipedEnvironment()` block above: same -// wipe, guard registered afterwards, and the ban is refused. Together the two -// isolate what the bootstrap predicate contributes, which neither can do alone. +// Switching the set off leaves no dangling grant to read: the definition is +// still there, still named, and grants nothing. Read naively, the bootstrap +// exemption would fire on that emptiness and wave every later write through. +// The environment is built the only way it is still reachable now that the +// write itself is guarded: the switch-off landed while the guard was NOT +// registered, and the platform then boots with the guard on. // --------------------------------------------------------------------------- -describe('[#6084] reverse verification: one unguarded write takes the admins AND the guard', () => { - it('the unguarded engine deletes the admin_full_access row and every platform admin evaporates', async () => { +describe('[#8613] a SWITCHED-OFF break-glass set is an emptied environment, not a fresh one', () => { + async function switchedOffEnvironment(): Promise { const engine = await boot({ unguarded: true }); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - - await expect( - engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }), - ).resolves.toBeDefined(); - // The issue's end state: the user row, its account and its grant are all - // exactly as they were — which is precisely why the first three shapes see - // nothing wrong — and no `admin_full_access` row is left to resolve. - expect(await userExists(engine, 'usr_platform')).toBe(true); - expect(await bannedFlag(engine, 'usr_platform')).toBeFalsy(); - expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(false); - }); - - it('THE AMPLIFICATION: on that same engine the ban of the last administrator then succeeds', async () => { - const engine = await boot({ unguarded: true }); - await seedAdminPermissionSet(engine); - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await engine.delete('sys_permission_set', { where: { id: PS_ADMIN }, ...SYSTEM }); - await expect(ban(engine, 'usr_platform')).resolves.toBeTruthy(); - expect(await bannedFlag(engine, 'usr_platform')).toBeTruthy(); - }); -}); - -// --------------------------------------------------------------------------- -// [#8613 / ADR-0049] Write shape (4), third spelling: DEACTIVATING the row -// -// `sys_permission_set.active` used to be inert — a badge in Setup and nothing -// else — so the #6084 standing-key list could exclude it in writing, and did. -// Enforcing the flag at the resolution seam makes `active: false` on -// `admin_full_access` un-make every platform admin at once, by a payload that -// touches neither `name` nor any identity table, through a row action that -// carries no visibility or condition guard. Unguarded, that is one click and an -// installation-wide lockout with no path back: the seeders deliberately never -// reconcile `active`, and re-activating needs the permission just lost. -// -// Deactivation also leaves NO dangling grant, so the #6084 bootstrap predicate -// cannot see it — the set row is still there, still correctly named. Hence the -// second half of this block: the same emptiness, its own evidence, its own -// remedy, and an exemption for the write that IS the remedy. -// --------------------------------------------------------------------------- - -describe('[#8613] path 4, third spelling — deactivating the admin_full_access permission set', () => { - let engine: ObjectQL; - - beforeEach(async () => { - engine = await boot(); - await seedAdminPermissionSet(engine); - }); - - /** Exactly what the `deactivate_permission_set` row action PATCHes. */ - const deactivate = (id: string) => - engine.update('sys_permission_set', { id, active: false }, SYSTEM); - - /** - * Whether the STORED row reads as deactivated, in whichever shape sqlite - * hands back — `0`, `false`, or the NULL a never-written column keeps. The - * predicate under test treats absent as ACTIVE, so this asserts the write did - * not land rather than asserting one particular spelling of "off". - */ - const isDeactivated = async (id: string): Promise => { - const row = await engine.findOne('sys_permission_set', { where: { id } }, SYSTEM); - const flag = row?.active as unknown; - return flag === 0 || flag === false || flag === '0'; - }; - - it('THE ONE-CLICK LOCKOUT: deactivating it is refused and the row stays active', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(deactivate(PS_ADMIN)).rejects.toMatchObject({ - code: 'PERMISSION_DENIED', - status: 403, - object: 'sys_permission_set', - }); - // Nothing was written — the row is still there and still grants. - expect(await isDeactivated(PS_ADMIN)).toBe(false); - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(true); - }); - - it('no identity table is touched, exactly as in the delete and rename spellings', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - - await expect(deactivate(PS_ADMIN)).rejects.toThrow(/ADR-0135 D5\.2/); - await expectUserRowUntouched(engine, 'usr_platform'); - expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true); - }); - - it('the refusal names the user who would lose standing', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(deactivate(PS_ADMIN)).rejects.toThrow(/'usr_platform'/); - await expect(deactivate(PS_ADMIN)).rejects.toThrow(/last administrator/i); - }); - - it('RE-activating is never refused — the payload is simulated, not pattern-matched', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - // Same column, same standing-key hit, opposite direction: the simulation - // finds the administrator still there afterwards, so it proceeds. - await expect( - engine.update('sys_permission_set', { id: PS_ADMIN, active: true }, SYSTEM), - ).resolves.toBeTruthy(); - }); - - it('an org admin elsewhere keeps the deactivation legal — the invariant is the ENVIRONMENT\'s', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await seedUser(engine, 'usr_owner', { role: 'owner' }); - - await expect(deactivate(PS_ADMIN)).resolves.toBeTruthy(); - }); - - it('deactivating ANOTHER permission set is unaffected', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(deactivate('ps_member')).resolves.toBeTruthy(); - expect(await isDeactivated('ps_member')).toBe(true); - expect(await isDeactivated(PS_ADMIN)).toBe(false); - }); - - it('a predicate write that sweeps every set at once is refused', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect( - engine.update( - 'sys_permission_set', - { active: false }, - { multi: true, where: { id: { $in: [PS_ADMIN, 'ps_member'] } }, ...SYSTEM }, - ), - ).rejects.toThrow(/last administrator/i); - }); - - it('a payload that touches NEITHER `name` nor `active` still costs no reads at all', async () => { - const quiet = await boot({ - readThrough: (real) => ({ - registerHook: (event, handler, options) => real.registerHook(event, handler, options), - find: async () => { - throw new Error('the guard must not read anything for a row-state-free payload'); - }, - }), - }); - await seedAdminPermissionSet(quiet); - await seedUser(quiet, 'usr_platform', { platformAdmin: true }); - - // Adding `active` to the standing keys must not walk back the #6084 - // read-freeness: the projection is facets-only and never re-flips the - // switch, so every projection pass and `os meta resync` still skips this - // guard statically. Any read would surface as a refusal. - await expect( - quiet.update('sys_permission_set', { id: PS_ADMIN, label: 'Full Access (edited)' }, SYSTEM), - ).resolves.toBeTruthy(); - }); -}); - -// --------------------------------------------------------------------------- -// [ADR-0131 D4] Write shape (4), fourth spelling: MOVING the row into an -// organization -// -// The resolver finds an unscoped grant's `admin_full_access` by name on the -// ORGANIZATION-LESS row only. Stamping that row with an organization leaves it -// named and active, and still un-makes every grant-anchored platform admin — -// by a payload that touches neither `name` nor `active`. -// --------------------------------------------------------------------------- - -describe('[ADR-0131 D4] path 4, fourth spelling — moving the admin_full_access permission set into an organization', () => { - let engine: ObjectQL; - - beforeEach(async () => { - engine = await boot(); - await seedAdminPermissionSet(engine); - }); - - const move = (id: string, organizationId: string | null) => - engine.update('sys_permission_set', { id, organization_id: organizationId }, SYSTEM); - - const organizationOf = async (id: string): Promise => - ((await engine.findOne('sys_permission_set', { where: { id } }, SYSTEM)) as any)?.organization_id ?? null; - - it('moving it is refused and the row stays organization-less', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(move(PS_ADMIN, 'org_elsewhere')).rejects.toMatchObject({ - code: 'PERMISSION_DENIED', - status: 403, - object: 'sys_permission_set', - }); - expect(await organizationOf(PS_ADMIN)).toBeNull(); - }); - - it('an org admin elsewhere keeps the move legal — the invariant is the ENVIRONMENT\'s', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await seedUser(engine, 'usr_owner', { role: 'owner' }); - - await expect(move(PS_ADMIN, 'org_elsewhere')).resolves.toBeTruthy(); - }); - - it('moving ANOTHER permission set, or repeating the row’s own organization-less value, is unaffected', async () => { - await seedUser(engine, 'usr_platform', { platformAdmin: true }); - - await expect(move('ps_member', 'org_elsewhere')).resolves.toBeTruthy(); - await expect(move(PS_ADMIN, null)).resolves.toBeTruthy(); - expect(await organizationOf(PS_ADMIN)).toBeNull(); - }); -}); - -describe('[#8613] a DEACTIVATED break-glass set is an emptied environment, not a fresh one', () => { - /** - * The state the third spelling leaves behind, reachable the same way #6084's - * is: the deactivation lands while the guard is not registered (a pre-#8613 - * deployment that clicked Deactivate while the flag was inert, a migration, a - * direct database edit), and the platform then boots with the guard on. - * - * This is the population the behaviour flip lands hardest on, which is why it - * is pinned rather than reasoned about: on those installations `active:false` - * was a no-op until this change, so the row can already be off on upgrade. - */ - async function deactivatedEnvironment(): Promise { - const engine = await boot({ unguarded: true }); - await seedAdminPermissionSet(engine); - await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - await engine.update('sys_permission_set', { id: PS_ADMIN, active: false }, SYSTEM); - registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { - packageId: 'test.last-admin-guard', - }); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, false), SYSTEM); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard' }); return engine; } - it('THE AMPLIFIER PIN: the ban the bootstrap exemption would have waved through is refused', async () => { - const engine = await deactivatedEnvironment(); + it('THE REGRESSION PIN: the ban the exemption would wave through is refused', async () => { + const engine = await switchedOffEnvironment(); - await expect(ban(engine, 'usr_platform')).rejects.toMatchObject({ - code: 'PERMISSION_DENIED', - status: 403, - }); + await expect(ban(engine, 'usr_platform')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); expect(await bannedFlag(engine, 'usr_platform')).toBeFalsy(); }); it('…and the user delete and the grant revoke with it — all three halves stay on', async () => { - const engine = await deactivatedEnvironment(); + const engine = await switchedOffEnvironment(); - await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/DEACTIVATED/); + await expect(removeUser(engine, 'usr_platform')).rejects.toThrow(/switched OFF/); await expect( engine.delete('sys_user_permission_set', { where: { id: 'ups_usr_platform' }, ...SYSTEM }), - ).rejects.toThrow(/DEACTIVATED/); + ).rejects.toThrow(/switched OFF/); expect(await userExists(engine, 'usr_platform')).toBe(true); }); it('the refusal names the evidence, the cause and the way back', async () => { - const engine = await deactivatedEnvironment(); + const engine = await switchedOffEnvironment(); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(/recognises NO administrator/); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(/DEACTIVATED/); - // The remedy is the one that actually works here — re-activate, NOT the - // "restore the deleted row" sentence the #6084 wipe prescribes. - await expect(ban(engine, 'usr_platform')).rejects.toThrow(/Re-activate/); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(new RegExp(ADMIN_FULL_ACCESS)); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(/ADR-0135 D5\.2/); + let message = ''; + try { + await ban(engine, 'usr_platform'); + } catch (e) { + message = (e as Error).message; + } + expect(message).toMatch(/recognises NO administrator/); + expect(message).toMatch(/switched OFF in 'sys_metadata_activation'/); + expect(message).toMatch(/Switch the 'admin_full_access' permission set back on/); + expect(message).toMatch(/ADR-0135 D5\.2/); }); - it('THE WAY BACK IS OPEN: re-activating the set is permitted from inside that environment', async () => { - const engine = await deactivatedEnvironment(); + it('THE WAY BACK IS OPEN: switching the set back on is permitted from inside that environment', async () => { + const engine = await switchedOffEnvironment(); - // Every other guarded write is refused above. If the remedy the refusal - // prescribes were refused too, the guard itself would be the lockout. - await expect( - engine.update('sys_permission_set', { id: PS_ADMIN, active: true }, SYSTEM), - ).resolves.toBeTruthy(); - // …and the environment is whole again: the ordinary verdict is back. + await expect(engine.update('sys_metadata_activation', { id: 'act_admin', active: true }, SYSTEM)).resolves.toBeTruthy(); + expect(await ledgerActive(engine, 'act_admin')).toBeTruthy(); + // …and the administrator it restores is protected again by the ordinary verdict. await expect(ban(engine, 'usr_platform')).rejects.toThrow(/last administrator/i); }); - it('a deactivated set nobody holds an unscoped grant to is NOT evidence', async () => { - const engine = await boot(); - await seedAdminPermissionSet(engine); - await seedUser(engine, 'usr_a', { grant: { permission_set_id: 'ps_member' } }); - // No unscoped grant points at `admin_full_access`, so switching it off - // strands nobody — this is an ordinary pre-first-admin environment. - await engine.update('sys_permission_set', { id: PS_ADMIN, active: false }, SYSTEM); - - await expect(ban(engine, 'usr_a')).resolves.toBeTruthy(); - }); - - it('a genuinely fresh environment is untouched — no set is deactivated at all', async () => { - const engine = await boot(); + it('a switched-off set nobody holds an unscoped grant to is NOT evidence', async () => { + const engine = await boot({ unguarded: true }); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_first', { role: 'member' }); + await engine.insert('sys_metadata_activation', ledgerRow('act_admin', ADMIN_FULL_ACCESS, false), SYSTEM); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard' }); await expect(ban(engine, 'usr_first')).resolves.toBeTruthy(); }); }); // --------------------------------------------------------------------------- -// [#8613] Reverse verification — the same fixtures with the guard NOT registered +// [#6084] Reverse verification — the same fixtures with the guard NOT registered // // Direction, decided before running: RED, the usual one. Without -// `registerLastAdminGuard` the deactivation succeeds, every platform admin -// evaporates while every row survives untouched, and the ban that follows -// succeeds too. The guarded halves above are measured against exactly this. +// `registerLastAdminGuard` the fourth write succeeds and takes the whole +// grant-anchored population with it, and the ban that follows succeeds too — +// the amplification, on the engine as it behaves without this guard. // --------------------------------------------------------------------------- -describe('[#8613] reverse verification: unguarded, one click takes the admins AND the guard', () => { - it('the unguarded engine deactivates the row and leaves every other row intact', async () => { +describe('[#6084] reverse verification: one unguarded write takes the admins AND the guard', () => { + it('the unguarded engine switches admin_full_access off and every platform admin evaporates', async () => { const engine = await boot({ unguarded: true }); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_platform', { platformAdmin: true, accountProvider: 'oidc' }); - await expect( - engine.update('sys_permission_set', { id: PS_ADMIN, active: false }, SYSTEM), - ).resolves.toBeTruthy(); + await expect(switchOff(engine)).resolves.toBeDefined(); expect(await userExists(engine, 'usr_platform')).toBe(true); expect(await bannedFlag(engine, 'usr_platform')).toBeFalsy(); expect(await rowExists(engine, 'sys_user_permission_set', 'ups_usr_platform')).toBe(true); - // The row is STILL THERE and still correctly named — which is exactly why - // the #6084 dangling-grant predicate cannot see this state. - expect(await rowExists(engine, 'sys_permission_set', PS_ADMIN)).toBe(true); + expect(await ledgerActive(engine, `act_${ADMIN_FULL_ACCESS}`)).toBeFalsy(); }); it('THE AMPLIFICATION: on that same engine the ban of the last administrator then succeeds', async () => { @@ -2348,7 +2128,7 @@ describe('[#8613] reverse verification: unguarded, one click takes the admins AN await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_platform', { platformAdmin: true }); - await engine.update('sys_permission_set', { id: PS_ADMIN, active: false }, SYSTEM); + await switchOff(engine); await expect(ban(engine, 'usr_platform')).resolves.toBeTruthy(); expect(await bannedFlag(engine, 'usr_platform')).toBeTruthy(); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index d57030a02ed..83e56d3b6b4 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -39,28 +39,26 @@ * ADR-0091 validity window). The end state is identical to (2): everyone is * still there, nobody can administer anything, and there is no recovery * path from inside the product. - * 4. **deleting, RENAMING — or DEACTIVATING — the `admin_full_access` - * `sys_permission_set` row** (#6084) — the one table the enumeration reads - * that is not itself an identity table. "Who is a platform admin" is - * resolved by NAME: the first step of `resolveAdminUserIds` looks the - * permission set up as `where: { name: 'admin_full_access' }` and only then - * reads the grants naming it ([ADR-0131] D4: the grant's `permission_set` - * column, no longer the row's id). Remove that row, call it something - * else, or (ADR-0049, since `active` became a resolution-time predicate) - * switch it off, and every grant, every `sys_user` row and every - * `sys_member` row survives untouched while nobody is a platform admin any - * more — one write, the whole GRANT-anchored platform-admin population. - * ([#11973] Since the #11663 re-anchor that is no longer the whole - * population: a CONFIG-anchored administrator — shape (5)'s subject — - * survives every write to this table, so where one stands these three - * refusals are priced away by the enumeration itself and the write is - * permitted; they still hold wherever the row remains the load-bearing - * anchor. See the re-pricing note in `resolveAdminUserIds`.) Unlike (3) this one - * is not driven by an IdP at all: it is written by a metadata delete, an - * `os meta` run, a package uninstall — or, for the deactivation spelling, by - * one click on a Setup row action that carries no visibility or condition - * guard — which is why it needs its own two hooks rather than a wider filter - * on the three tables above. + * 4. **switching the `admin_full_access` permission set OFF** (#6084, as + * ADR-0131 D3 moves it) — the one standing input that is not itself an + * identity table. "Who is a platform admin" is resolved by NAME: a grant's + * `permission_set` names `admin_full_access` ([ADR-0131] D4), the set is + * the security catalog's definition of that name, and whether it is in + * effect is the activation ledger's answer (`sys_metadata_activation`, + * ADR-0126 §4 regime C as ADR-0131 D6 amends it). One ledger write — the + * set's row switched off, or another row moved onto that pair — leaves + * every grant, every `sys_user` row and every `sys_member` row untouched + * while nobody is a grant-anchored platform admin any more. ([#11973] A + * CONFIG-anchored administrator — shape (5)'s subject — survives it, so + * where one stands the write is permitted; it is refused wherever the + * grant remains the load-bearing anchor. See the re-pricing note in + * `resolveAdminUserIds`.) Unlike (3) it is not driven by an IdP at all, + * which is why it has its own two hooks. The `sys_permission_set` row this + * shape used to name is no longer read by the resolver, so writing it — + * a delete, a rename, its `active` column — moves no administrator and is + * not judged here; a set DEFINITION that goes away (an uninstalled + * package) is not a write this guard can see, and is read instead as the + * emptied state below. * 5. **moving a `sys_user` row off the DECLARED administrator list** (#11663 * L2) — the first shape that revokes nothing stored. Since the * platform-admin re-anchor, `resolveAuthzContext` also derives @@ -80,8 +78,9 @@ * recovery path from inside the product once that happens. * * So the invariant is enforced at the WRITE, on the chokepoints every path goes - * through — `beforeUpdate` and `beforeDelete` on `sys_user`, `sys_member`, - * `sys_user_permission_set` and `sys_permission_set` — rather than at any + * through — `beforeUpdate` and `beforeDelete` on `sys_user`, `sys_member` and + * `sys_user_permission_set`, and `beforeInsert` / `beforeUpdate` on the + * activation ledger — rather than at any * individual endpoint. * HTTP-level guards protect only the endpoints they are attached to; these hold * for the admin ban / remove endpoints, `updateMemberRole`, the SCIM adapter @@ -92,10 +91,10 @@ * The row halves can answer by set arithmetic — "is every unbanned * administrator inside the doomed set of `sys_user` ids?". The standing halves * cannot: the write does not name users at all, it edits the evidence the - * administrator set is DERIVED from. (#6084's two permission-set halves are + * administrator set is DERIVED from. (The two activation-ledger halves are * standing halves in exactly this sense, and reuse the same three steps — the - * `sys_permission_set` row is a step further from the user than a grant is, not - * a different kind of evidence.) So they answer the way the issue framed it — + * ledger row is a step further from the user than a grant is, not a different + * kind of evidence.) So they answer the way the issue framed it — * **enumerate, simulate, enumerate again**: * * 1. enumerate the administrators as the tables read now; @@ -183,7 +182,7 @@ * * Write shape (4) turns that exemption into an AMPLIFIER, which is the half of * #6084 that matters more than the fourth pair of hooks: an environment whose - * `admin_full_access` row is gone reads as ZERO administrators, so the exemption + * `admin_full_access` set is switched off reads as ZERO administrators, so the exemption * fires and every OTHER path — ban, delete, downgrade, revoke — is waved through * as well. One write does not just lock the environment out, it disables the * whole guard on the way. @@ -194,26 +193,23 @@ * - **a genuinely fresh environment** — no evidence anybody was ever a platform * admin here. Permitted, exactly as before. * - **an environment that was EMPTIED** — an unscoped, in-window - * `sys_user_permission_set` grant still points at a `sys_permission_set` row - * that no longer exists. Refused, loudly, naming the dangling grants. - * - **an environment whose break-glass set was switched OFF** (ADR-0049) — the - * `admin_full_access` row is present and named correctly, and unscoped, - * in-window grants still point at it, but `active` is false so it confers - * nothing. Refused too, with its own remedy: re-activate the row. This state - * became reachable the moment `active` became a resolution-time predicate, - * and it produces the identical emptiness while leaving no dangling grant to - * read. A write that RESTORES standing — re-activation itself — is exempt, - * measured through the same simulation, or the refusal would have no way out - * from inside the product. + * `sys_user_permission_set` grant still names a permission set the security + * catalog no longer holds (ADR-0131 D4). Refused, loudly, naming the + * dangling grants. + * - **an environment whose break-glass set was switched OFF** (ADR-0049, + * ADR-0131 D3) — the `admin_full_access` definition is there, and unscoped, + * in-window grants still name it, but the activation ledger says it is off + * so it confers nothing. Refused too, with its own remedy: switch it back + * on. It produces the identical emptiness while leaving no dangling grant + * to read. A write that RESTORES standing — switching it back on — is + * exempt, measured through the same simulation, or the refusal would have + * no way out from inside the product. * * The evidence is chosen so the FRESH-INSTALL answer cannot change: a dangling - * grant is unreachable on the happy path in either direction. Every writer - * inserts the permission set first and reads its id back to write the grant — - * `bootstrapPlatformAdmin` seeds the set rows in step 1 and only then promotes - * the first user in step 2, returning `admin_permission_set_missing` rather than - * granting when the set is absent — so no ordering of a fresh boot produces one. - * It is produced by exactly one thing: DELETING a permission set that grants - * already point at. + * grant is unreachable on the happy path. `admin_full_access` is a platform + * set shipped on `plugin-security`'s own manifest, so the catalog holds it + * before any grant names it. It is produced by a set definition going away + * while grants still name it. * * A RENAME leaves no dangling grant, so this predicate cannot see it: the row is * still there and every grant still resolves. That path is closed at the WRITE @@ -283,8 +279,8 @@ * policy with its own product decisions (what happens to an org whose only * owner leaves the company); it is deliberately not invented here. * - * Scope in the other direction: this guard watches writes to the four tables - * the administrator population is derived from, and stops there. It has no + * Scope in the other direction: this guard watches writes to the tables the + * administrator population is derived from, and stops there. It has no * opinion on what a permission set CONTAINS, and that is not a gap being left * open — it was measured. `resolveAuthzContext` sets `hasPlatformAdminGrant` * from `ps.name === 'admin_full_access'` alone and `derivePosture` returns @@ -321,12 +317,12 @@ import { } from '@objectstack/spec/identity'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { SystemObjectName, SystemUserId } from '@objectstack/spec/system'; -import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/types'; +import { PLATFORM_OWNER_EMAIL_ENV, isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; import { isGrantActive, - isRowActive, matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails, + securityCatalogReaderOf, } from '@objectstack/core'; import { isOrgAdminGrade } from './invitation-role-cap.js'; @@ -335,6 +331,9 @@ import { GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD, grantSetNameOf } from './gran /** `sys_user_permission_set` has no `SystemObjectName` member; it is spelled once, here. */ const USER_PERMISSION_SET = 'sys_user_permission_set'; +/** [ADR-0131 D3, ADR-0126 §4] The activation ledger the resolver honours deactivation from. */ +export const METADATA_ACTIVATION = 'sys_metadata_activation'; + type LoggerLike = { info(msg: string): void; warn(msg: string): void; @@ -386,8 +385,9 @@ const SYSTEM_READ: BaseEngineOptions = { context: { isSystem: true } }; * * The first two take the administrator away with their `sys_user` row; the four * `standing` ops (#5978) leave the row untouched and take away what MAKES them - * an administrator; the last two (#6084) take away the `sys_permission_set` row - * that the platform-admin half of the enumeration resolves BY NAME. + * an administrator; the two activation ops (#6084, ADR-0131 D3) switch off the + * `admin_full_access` set the platform-admin half of the enumeration resolves + * BY NAME. */ type GuardedOp = | 'ban' @@ -396,8 +396,8 @@ type GuardedOp = | 'member-delete' | 'grant-update' | 'grant-delete' - | 'permission-set-update' - | 'permission-set-delete' + | 'activation-insert' + | 'activation-update' | 'user-standing-update'; interface OpWords { @@ -462,21 +462,21 @@ const OP_WORDS: Record = { subject: 'permission-set grants', table: USER_PERMISSION_SET, }, - 'permission-set-update': { - noun: 'permission-set rename', - verb: 'rename', - gerund: 'renaming', - Verb: 'Rename', - subject: 'permission sets', - table: SystemObjectName.PERMISSION_SET, + 'activation-insert': { + noun: 'activation-ledger write', + verb: 'switch off', + gerund: 'switching off', + Verb: 'Switch off', + subject: 'catalog items', + table: METADATA_ACTIVATION, }, - 'permission-set-delete': { - noun: 'permission-set removal', - verb: 'remove', - gerund: 'removing', - Verb: 'Remove', - subject: 'permission sets', - table: SystemObjectName.PERMISSION_SET, + 'activation-update': { + noun: 'activation-ledger change', + verb: 'switch off', + gerund: 'switching off', + Verb: 'Switch off', + subject: 'catalog items', + table: METADATA_ACTIVATION, }, // [#11663 L2] Deliberately generic wording: ONE payload can carry an address // change and an `email_verified` reset together, and the refusal has to read @@ -498,17 +498,17 @@ const OP_WORDS: Record = { * of this guard. * * The two identity tables are written by IdP integrations, so their refusals - * point at the SCIM group mapping. `sys_permission_set` is not — nothing in - * SCIM or better-auth writes it; a metadata delete, an `os meta` run or a - * package uninstall does. Sending THAT operator to look at an IdP group would - * send them into a system they may not even run. + * point at the SCIM group mapping. The activation ledger is not — nothing in + * SCIM or better-auth writes it; a disable action, an upgrade step or a script + * does. Sending THAT operator to look at an IdP group would send them into a + * system they may not even run. */ function standingOrigin(table: string, noun: string): string { - if (table === SystemObjectName.PERMISSION_SET) { + if (table === METADATA_ACTIVATION) { return ( - `If the ${noun} came from a metadata delete, an 'os meta' run or a package uninstall, ` + - `revoke the '${ADMIN_FULL_ACCESS}' grants first — the permission-set row every platform ` + - 'admin is derived from is the last thing an environment gives up, not the first.' + `If the ${noun} came from a disable action, an upgrade step or a script, revoke the ` + + `'${ADMIN_FULL_ACCESS}' grants first — the switch every platform admin is derived from is ` + + 'the last thing an environment gives up, not the first.' ); } if (table === SystemObjectName.USER) { @@ -555,46 +555,6 @@ function refuse(message: string, object: string = SystemObjectName.USER): Error return err; } -/** - * [ADR-0131 D3, ADR-0126 §4] Refuse an activation-ledger write that would - * switch the `admin_full_access` permission set off. A write that does not set - * `active` false passes. An update that does not carry the row's type and name - * reads them back by `id`. One whose row cannot be read is refused: the - * failure mode is an installation-wide lockout. - */ -export async function refuseLedgerSwitchingAdminOff( - engine: { find: (object: string, query: EngineQueryOptions, options?: BaseEngineOptions) => Promise }, - data: Record, - id: unknown, -): Promise { - if (!(data.active === false || data.active === 0)) return; - let metadataType = data.metadata_type; - let name = data.name; - if (metadataType === undefined || name === undefined) { - const rows = id === undefined || id === null - ? [] - : await engine.find(METADATA_ACTIVATION, { where: { id }, limit: 1 } as EngineQueryOptions, SYSTEM_READ); - const row = Array.isArray(rows) ? (rows[0] as Record | undefined) : undefined; - if (!row) { - throw refuse( - `Refusing this '${METADATA_ACTIVATION}' write: it switches a row off and the row it targets could ` - + `not be read, so it may be the '${ADMIN_FULL_ACCESS}' permission set (${BREAK_GLASS_CITATION}).`, - METADATA_ACTIVATION, - ); - } - metadataType ??= row.metadata_type; - name ??= row.name; - } - if (metadataType === 'permission' && name === ADMIN_FULL_ACCESS) { - throw refuse( - `Refusing this '${METADATA_ACTIVATION}' write: switching the '${ADMIN_FULL_ACCESS}' permission set off ` - + 'un-makes every grant-derived platform administrator at once, with no identity table touched ' - + `(${BREAK_GLASS_CITATION}).`, - METADATA_ACTIVATION, - ); - } -} - /** Marker so the fail-closed wrapper re-throws a deliberate refusal unchanged. */ function isRefusal(err: unknown): boolean { return (err as { code?: string } | null)?.code === 'PERMISSION_DENIED'; @@ -620,15 +580,20 @@ function toId(value: unknown): string | undefined { */ interface PendingStandingWrite { /** - * `sys_member`, `sys_user_permission_set`, `sys_permission_set` (#6084) or — - * since #11663 L2 — `sys_user`, whose `email` / `email_verified` pair is the - * config anchor's half of the derivation. + * `sys_member`, `sys_user_permission_set`, the activation ledger + * (ADR-0131 D3) or — since #11663 L2 — `sys_user`, whose `email` / + * `email_verified` pair is the config anchor's half of the derivation. */ table: string; /** Ids of the rows this one write addresses (by-id, or the predicate's matches). */ ids: Set; /** The update payload, or `undefined` for a delete. */ patch?: Record; + /** + * [ADR-0131 D3] A row this write INSERTS — the activation ledger's only + * standing write that addresses no existing row (`ids` is then empty). + */ + inserted?: Record; } /** @@ -721,73 +686,6 @@ export const GRANT_STANDING_KEYS = [ 'validUntil', ] as const; -/** - * [#6084] Same, for `sys_permission_set` — the two columns of that table the - * platform-admin half of the enumeration reads: - * - * - `name`, the column it looks the set up by. Renaming the row takes the - * standing away from everyone holding a grant to it, in one write. - * - `active` [ADR-0049]. This column USED to be inert, and this comment used - * to say so: `resolveAuthzContext` derived `platform_admin` from the name - * alone and read no flag. It now drops a DEACTIVATED set before any - * derivation, so `active: false` on `admin_full_access` un-makes every - * GRANT-anchored platform admin at once — the same end state as renaming or - * deleting the row, reached by a payload that touches neither. ⚠️ Not - * "every platform admin": since the #11663 re-anchor (L2) standing has a - * SECOND anchor this write cannot reach — a config-anchored administrator - * (a declared `OS_PLATFORM_OWNER_EMAIL` address on a VERIFIED `sys_user` - * row) is derived at `resolve-authz-context.ts` §6b-config without - * consulting the set row or its `active` flag at all, and carries the - * shipped `ADMIN_FULL_ACCESS_CAPABILITIES` envelope rather than the stored - * set's. That is deliberately NOT a reason to drop `active` from this - * list: the write can still empty the GRANT anchor, which on every - * deployment that has declared no administrator emails is the whole - * population. Listing it is an over-approximation in the SAFE direction — - * it can cost an enumeration on a write that turns out to change no count, - * never the reverse — and taking it out would be a behaviour change, not a - * comment fix. Enforcing the flag without - * listing it here would have left exactly one unguarded route to an - * installation-wide lockout: the action is offered on every row with no - * visibility or condition guard, the seeders deliberately never reconcile - * `active`, and re-activating requires the permission the click just took - * away. - * - * Everything else a permission-set write touches (`label`, `description`, the - * four permission JSON blobs, provenance) is still invisible to "who is an - * administrator" — `resolveAuthzContext` derives `platform_admin` from the NAME - * of an ACTIVE set or, since the #11663 re-anchor (L2), from the - * deployment-config anchor, and from the capabilities of neither (the config - * arm's envelope is the shipped `ADMIN_FULL_ACCESS_CAPABILITIES` declaration, - * not the stored row) — so those writes still cost this guard no reads at all. - * Adding `active` does not walk that back: the projection is FACETS ONLY and - * deliberately never re-flips a record's on/off switch - * (`permission-set-projection.ts`, #4669), so every projection pass, every - * `os meta resync` and every ordinary Setup edit still misses this list - * entirely. What now pays for an enumeration is the write that actually - * toggles the switch — which is the write this list exists to judge. - * - * [ADR-0131 D4] `organization_id`, because the resolver finds an unscoped - * grant's `admin_full_access` on the ORGANIZATION-LESS row only: moving that - * row into an organization un-makes every grant-anchored platform admin, the - * same end state as renaming or deleting it, reached by a payload that touches - * neither `name` nor `active`. Both spellings, as the grant list carries them. - * - * `id` is deliberately NOT here even though the enumeration reads it. On this - * engine `data.id` on an update ADDRESSES the row (it is what - * `resolveTargetIds` resolves the target from) rather than proposing a new - * primary key, so a key rewrite is not expressible through this write path; the - * two standing key lists above exclude `id` for the same reason. - * - * `sys_position` gets no analogous list because it has no route into this - * enumeration to guard: platform-admin standing is read from UNSCOPED - * `sys_user_permission_set` grants and from the deployment-config anchor (a - * declared `OS_PLATFORM_OWNER_EMAIL` address on a VERIFIED `sys_user` row, - * which `USER_STANDING_KEYS` below guards) — a position-bound - * `admin_full_access` reaches neither, in the resolver or here — and - * org-administrator standing is read from `sys_member.role`. Deactivating a - * position cannot empty any of them. - */ -export const PERMISSION_SET_STANDING_KEYS = ['name', 'active', 'organization_id', 'organizationId'] as const; /** * [#11663 L2] Same, for `sys_user` — the FIFTH write shape, and the first one @@ -820,19 +718,28 @@ export const PERMISSION_SET_STANDING_KEYS = ['name', 'active', 'organization_id' */ export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; -/** [ADR-0131 D3, ADR-0126 §4] The activation ledger the resolver honours deactivation from. */ -export const METADATA_ACTIVATION = 'sys_metadata_activation'; - /** - * [ADR-0131 D3, ADR-0126 §4] Same, for the activation ledger. Since the - * catalog switch the resolver reads a permission set's deactivation from the - * ledger, not from the `sys_permission_set` row, so a ledger row switching - * `admin_full_access` off un-makes every grant-derived platform administrator. + * [ADR-0131 D3, ADR-0126 §4] Same, for the activation ledger — the table the + * resolver reads a permission set's deactivation from. A `permission` row of + * `admin_full_access` whose `active` is false un-makes every grant-anchored + * platform administrator at once, with no identity table touched: the end + * state #6084 named for deleting the set, reached by a payload that deletes + * nothing. `metadata_type` and `name` are listed because a write can move a row + * ONTO that pair as well as switch the pair's own row; `active` because it is + * the switch. + * + * Judged by the same enumerate–simulate–enumerate as every standing half, so a + * switch-off is refused only when it would leave no administrator who can sign + * in (a config-anchored or organization administrator still standing permits + * it). A ledger DELETE is not judged: absence of a row means ACTIVE, so + * removing one can only restore standing. * - * Judged by {@link refuseLedgerSwitchingAdminOff}: a write that would switch - * it off is refused outright, with no enumeration. The ledger is engine-owned - * (the data API reads it only) and no enable/disable door accepts the - * `permission` type today, so the refusal binds the system-context writes alone. + * Two catalog tables get no list, because the resolver no longer reads them: + * `sys_permission_set` (the set's existence and body are its catalog + * definition, ADR-0131 D4) and `sys_position` (a position-bound + * `admin_full_access` never made a platform administrator, and organization + * standing is read from `sys_member.role`). Writing either row cannot move the + * administrator population, so neither is guarded. */ export const ACTIVATION_LEDGER_STANDING_KEYS = ['metadata_type', 'name', 'active'] as const; @@ -968,6 +875,69 @@ export function registerLastAdminGuard( return list; }; + /** + * [ADR-0131 D3/D4] Whether the catalog bound to this engine holds an + * `admin_full_access` permission definition — the set's existence, read + * where the resolver reads it. Resolved at the write, never at + * registration: the security plugin binds the reader at its `start()`. + * A catalog read that fails throws (`AuthzStoreUnavailableError`) and the + * write is refused by {@link failClosed}. + */ + const adminSetDefined = async (): Promise => { + const catalog = securityCatalogReaderOf(engine); + return catalog ? (await catalog.resolve('permission', ADMIN_FULL_ACCESS)) !== undefined : false; + }; + + /** The permission names the catalog holds — what a grant can name and resolve to. */ + const catalogSetNames = async (): Promise> => { + const catalog = securityCatalogReaderOf(engine); + return new Set(catalog ? (await catalog.list('permission')).map((entry) => entry.name) : []); + }; + + /** + * [ADR-0131 D3, ADR-0126 §4] The activation-ledger rows this guard reads, + * read as the resolver reads them: a composition that registers no ledger + * object holds no row (the read is not issued), and a ledger table never + * provisioned holds none either. Any other failed read throws, and the write + * is refused by {@link failClosed}. + */ + const scanLedger = async (op: GuardedOp, query: EngineQueryOptions): Promise>> => { + const registry = (engine as { registry?: { getObject?: (name: string) => unknown } }).registry; + if (typeof registry?.getObject === 'function' && !registry.getObject(METADATA_ACTIVATION)) return []; + try { + return await scan(op, METADATA_ACTIVATION, query); + } catch (err) { + if (isMissingTableError(err, METADATA_ACTIVATION)) return []; + throw err; + } + }; + + /** + * [ADR-0131 D3, ADR-0126 §4] Whether the activation ledger switches + * `admin_full_access` off once `pending` lands: a `permission` row of that + * name whose `active` reads false (a driver `0` included), the resolver's own + * reading. With a pending ledger write, the rows it addresses are read as + * well — an update can move a row ONTO that pair — and an inserted row is + * judged with them. + */ + const readAdminSetSwitchedOff = async (op: GuardedOp, pending?: PendingStandingWrite): Promise => { + const fields = ['id', 'metadata_type', 'name', 'active']; + const rows = await scanLedger(op, { where: { metadata_type: 'permission', name: ADMIN_FULL_ACCESS }, fields }); + const ledgerWrite = pending?.table === METADATA_ACTIVATION ? pending : undefined; + if (ledgerWrite && ledgerWrite.ids.size > 0) { + const seen = new Set(rows.map((row) => toId(row.id))); + const missing = [...ledgerWrite.ids].filter((id) => !seen.has(id)); + if (missing.length > 0) rows.push(...(await scanLedger(op, { where: { id: { $in: missing } }, fields }))); + } + const after = rows.map((row) => applyPending(row, pending, METADATA_ACTIVATION)); + if (ledgerWrite?.inserted) after.push(ledgerWrite.inserted); + return after.some((row) => + row !== undefined + && row.metadata_type === 'permission' + && row.name === ADMIN_FULL_ACCESS + && (row.active === false || row.active === 0)); + }; + /** * Every user this environment recognises as an administrator. * @@ -1009,60 +979,25 @@ export function registerLastAdminGuard( // It asks the ENVIRONMENT, never the engine, so it adds no read — the same // property §6b pins on its own side. // - // [#6084] The set row is simulated exactly like the grant rows below it: a - // pending write on `sys_permission_set` can DELETE this row (it drops out of - // `adminSetIds`, and with it every grant that pointed at it), RENAME it, or - // DEACTIVATE it, and each is RE-TESTED for the same reason the grant's - // `permission_set_id` is — the scan's own `where` only proved what the row - // was called BEFORE the write. - // - // [ADR-0049] `active` rides the projection because the flag is now read at - // resolution time: `fields` is a projection, so a column left out of this - // list would read as absent here and absent means ACTIVE — the guard would - // model an environment in which no set is ever deactivated and permit the - // one write that empties it. - // - // [ADR-0131 D4] A grant holds `admin_full_access` BY NAME — its - // `permission_set` column — so the grant half reads the grants NAMING the - // set, and the set rows only for whether the name is in effect: it is when - // at least one row bearing it survives the pending write still named so, - // and none of them is deactivated. With one row (the `single` catalog) - // that is exactly the row's own verdict; where a name has several rows - // (another organization's copy), one switched off is read as switching the - // name off — an under-count, the direction this guard may round in. A - // grant that names nothing is not counted at all: it holds no set by name, - // and `refuseIfEmptiedRatherThanFresh` reads it as evidence instead. + // [ADR-0131 D3/D4] The SAME two facts the resolver derives the grade from, + // read from the same two places: the set EXISTS when the security catalog + // bound to this engine holds a `permission` definition of that name + // (`securityCatalogReaderOf` — the reader `resolveUserAuthzGrants` reads + // set bodies through), and it is IN EFFECT unless the activation ledger + // switches it off ({@link readAdminSetSwitchedOff}). No `sys_permission_set` + // row is read: the resolver reads none, so a row cannot make or un-make an + // administrator, and the guard judges no write to it. An engine with no + // catalog bound resolves an empty catalog — no set grants — and is counted + // so here too. // - // [ADR-0131 D4] Only an ORGANIZATION-LESS row puts the name in effect: the - // resolver finds an unscoped grant's set on the organization-less row alone, - // never on an organization's copy, so a pending write that moves that row - // into an organization (`organization_id`) takes the standing away exactly - // as deleting it does. A copy switched off still reads as the name switched - // off — the under-count above, unchanged. + // A pending ledger write is simulated exactly like the grant rows below + // it, and re-tested for the same reason: the read's own `where` only proved + // what the ledger said BEFORE the write. const legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()); - const sets = legacyGrantAnchorRetired - ? [] - : await scan(op, SystemObjectName.PERMISSION_SET, { - where: { name: ADMIN_FULL_ACCESS }, - fields: ['id', 'name', 'active', 'organization_id'], - }); - let adminSetNamed = false; - let adminSetSwitchedOff = false; - for (const rawSet of sets) { - const set = applyPending(rawSet, pending, SystemObjectName.PERMISSION_SET); - if (!set) continue; // removed outright by the pending write - if (set.name !== ADMIN_FULL_ACCESS) continue; // renamed away — the row survives, the meaning does not - // Deactivated — the row survives under its own name and grants nothing, - // judged by the SAME predicate `resolveAuthzContext` resolves with. - if (!isRowActive(set)) { - adminSetSwitchedOff = true; - continue; - } - // An organization's copy — no unscoped grant resolves to it. - if (set.organization_id ?? set.organizationId) continue; - adminSetNamed = true; - } - if (adminSetNamed && !adminSetSwitchedOff) { + const adminSetInEffect = !legacyGrantAnchorRetired + && (await adminSetDefined()) + && !(await readAdminSetSwitchedOff(op, pending)); + if (adminSetInEffect) { const links = await scan(op, USER_PERMISSION_SET, { where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); @@ -1200,39 +1135,30 @@ export function registerLastAdminGuard( * that has just been emptied, and refuse in the second case. * * The evidence is a DANGLING platform-admin-shaped grant: an unscoped, - * in-window `sys_user_permission_set` row whose `permission_set_id` names a - * `sys_permission_set` row that is not there any more. Nobody writes such a - * row — every producer inserts the set first and reads its id back — so it - * can only be left behind by deleting a permission set that grants already - * pointed at, which is write shape (4) landing on `admin_full_access`. A - * fresh environment has no grants at all, or grants whose sets exist; either - * way this returns without refusing and the bootstrap window is untouched. + * in-window `sys_user_permission_set` row naming a permission set the + * security catalog does not hold, or naming none (ADR-0131 D4: a grant holds + * its set BY NAME, and the set is its catalog definition). On a fresh + * install every grant names a set the platform ships, so the fresh answer is + * unchanged; such a grant is left behind by a set definition that went away + * (an uninstalled package, a removed environment definition), or is an + * upgraded deployment's grant before the one-time backfill names it. * - * Deliberately an OVER-approximation of "the `admin_full_access` row was - * deleted": a dangling unscoped grant to some other set trips it too. The + * Deliberately an OVER-approximation of "the `admin_full_access` definition + * went away": a dangling unscoped grant to some other set trips it too. The * guard cannot tell the two apart (the name it would compare went away with - * the row), and refusing in an environment that has zero administrators AND a - * grant pointing into nowhere is the fail-closed direction. - * - * [ADR-0049] DEACTIVATION is the second way to reach the same emptiness, and - * it leaves NO dangling grant to read — the row is still there, still named - * `admin_full_access`, and simply grants nothing. Left unhandled, enforcing - * the flag would turn this exemption into the amplifier the paragraph above - * exists to prevent: one deactivation empties the administrator population - * and every later ban, delete and downgrade sails through unguarded. So the - * deactivated row with unscoped, in-window grants still pointing at it is - * read as the SAME evidence, with its own remedy — re-activate it. + * the definition), and refusing in an environment that has zero + * administrators AND a grant pointing into nowhere is the fail-closed + * direction. * - * [ADR-0131 D4] Read BY NAME, as the enumeration is: a dangling grant is one - * whose `permission_set` names a set no `sys_permission_set` row carries any - * more, and a grant that names NOTHING (`grantSetNameOf` is empty) is - * evidence too — the enumeration counts no such grant, so an environment - * whose administrators hold only such grants reads as empty, and the - * bootstrap window must not open on it. On a fresh install every grant names - * its set (the platform writes the name with the id), so the fresh answer is - * unchanged. Such a grant is an upgraded deployment's, before the one-time - * backfill names it at `kernel:bootstrapped`, or one the backfill could not - * name; refusing is the fail-closed reading of both. + * [ADR-0049, ADR-0131 D3] SWITCHING THE SET OFF is the second way to reach + * the same emptiness, and it leaves no dangling grant to read: the + * definition is still there and grants nothing while the activation ledger + * says it is off. Left unhandled, the bootstrap exemption would become the + * amplifier the paragraph above exists to prevent — one switch-off empties + * the administrator population and every later ban, delete and downgrade + * sails through unguarded. So a switched-off `admin_full_access` with + * unscoped, in-window grants still naming it is read as the SAME evidence, + * with its own remedy: switch it back on. */ const refuseIfEmptiedRatherThanFresh = async (op: GuardedOp): Promise => { // [#11663 L5] Both refusals below name ONE remedy — put the @@ -1251,14 +1177,8 @@ export function registerLastAdminGuard( + `${PLATFORM_OWNER_EMAIL_ENV} (a verified account's address, comma-separated for several) ` + 'instead; that is the only channel to platform admin standing under a wall.' : ''; - const sets = await scan(op, SystemObjectName.PERMISSION_SET, { fields: ['id', 'name', 'active'] }); - const known = new Set(); - let adminSetDeactivated = false; - for (const row of sets) { - if (typeof row.name !== 'string' || row.name === '') continue; - known.add(row.name); - if (row.name === ADMIN_FULL_ACCESS && !isRowActive(row)) adminSetDeactivated = true; - } + const known = await catalogSetNames(); + const adminSetDeactivated = known.has(ADMIN_FULL_ACCESS) && (await readAdminSetSwitchedOff(op)); // The deactivated-break-glass case, checked before the dangling one: it has // a precise diagnosis and a one-click remedy, so it must not be reported as @@ -1275,17 +1195,18 @@ export function registerLastAdminGuard( const words = OP_WORDS[op]; logger?.warn( `[LastAdminGuard] refused a ${words.noun} in an environment whose '${ADMIN_FULL_ACCESS}' ` + - `permission set is DEACTIVATED — ${stranded.length} unscoped grant(s) confer nothing`, + `permission set is switched OFF — ${stranded.length} unscoped grant(s) confer nothing`, ); throw refuse( `Refusing this ${words.noun}: this environment recognises NO administrator because its ` + - `'${ADMIN_FULL_ACCESS}' '${SystemObjectName.PERMISSION_SET}' row is DEACTIVATED — ` + + `'${ADMIN_FULL_ACCESS}' permission set is switched OFF in '${METADATA_ACTIVATION}' — ` + `${stranded.length} unscoped, in-window '${USER_PERMISSION_SET}' grant(s) still point ` + 'at it and confer nothing while it is off. That is not the bootstrap window, and ' + 'reading the resulting emptiness as "no administrator to protect" would switch this ' + - `guard off for every other write too (${BREAK_GLASS_CITATION}). Re-activate the ` + - `'${ADMIN_FULL_ACCESS}' permission set (set 'active' back to true) — the grants naming ` + - 'it are still there — before writing the identity tables again.' + walledRemedy, + `guard off for every other write too (${BREAK_GLASS_CITATION}). Switch the ` + + `'${ADMIN_FULL_ACCESS}' permission set back on (its '${METADATA_ACTIVATION}' row's ` + + "'active' back to true) — the grants naming it are still there — before writing the " + + 'identity tables again.' + walledRemedy, words.table, ); } @@ -1326,9 +1247,9 @@ export function registerLastAdminGuard( throw refuse( `Refusing this ${words.noun}: this environment recognises NO administrator, and this is not ` + `the bootstrap window — ${dangling.length} unscoped, in-window '${USER_PERMISSION_SET}' ` + - `grant(s) still name a permission set that no '${SystemObjectName.PERMISSION_SET}' row ` + - `carries any more, or name none yet (${dangling.join(', ')}). That is the state a DELETED '${ADMIN_FULL_ACCESS}' ` + - 'permission-set row leaves behind: it un-makes every platform admin at once, and ' + + 'grant(s) still name a permission set the security catalog does not hold, ' + + `or name none yet (${dangling.join(', ')}). That is the state a REMOVED '${ADMIN_FULL_ACCESS}' ` + + 'permission-set definition leaves behind: it un-makes every platform admin at once, and ' + 'reading the resulting emptiness as "no administrator to protect" would switch this guard ' + `off for every other write too (${BREAK_GLASS_CITATION}). Restore the ` + `'${ADMIN_FULL_ACCESS}' permission set — the grants naming it are still there — before ` + @@ -1516,8 +1437,20 @@ export function registerLastAdminGuard( | { id?: unknown; data?: Record; options?: { where?: unknown; multi?: unknown } } | undefined, patch?: Record, + inserted?: Record, ): Promise => { const words = OP_WORDS[op]; + // [ADR-0131 D3] An insert addresses no existing row: its pending write is + // the row it adds, and no target set is resolved (an id-less, `where`-less + // resolution would read the whole table). + const targetIds = (): Promise> => + inserted ? Promise.resolve(new Set()) : resolveTargetIds(op, table, input?.id, input?.options, input?.data); + const pendingOf = (ids: Set): PendingStandingWrite => ({ + table, + ids, + ...(patch ? { patch } : {}), + ...(inserted ? { inserted } : {}), + }); await failClosed(op, async () => { const before = await resolveAdminUserIds(op); // Same bootstrap exemption the row halves make, with the same #6084 @@ -1533,13 +1466,9 @@ export function registerLastAdminGuard( // not a special case for one column. Without it the refusal would be // unrecoverable from inside the product: the only fix is an update, and // every update would be refused. - const restoringIds = await resolveTargetIds(op, table, input?.id, input?.options, input?.data); - if (restoringIds.size > 0) { - const restored = await resolveAdminUserIds(op, { - table, - ids: restoringIds, - ...(patch ? { patch } : {}), - }); + const restoringIds = await targetIds(); + if (restoringIds.size > 0 || inserted) { + const restored = await resolveAdminUserIds(op, pendingOf(restoringIds)); if (restored.size > 0) return; } await refuseIfEmptiedRatherThanFresh(op); @@ -1554,10 +1483,10 @@ export function registerLastAdminGuard( // Which rows of the standing table this write addresses. For a predicate // write this resolves the whole matched set, so the simulation below is // exact for bulk writes rather than being refused wholesale. - const ids = await resolveTargetIds(op, table, input?.id, input?.options, input?.data); - if (ids.size === 0) return; + const ids = await targetIds(); + if (ids.size === 0 && !inserted) return; - const after = await resolveAdminUserIds(op, { table, ids, ...(patch ? { patch } : {}) }); + const after = await resolveAdminUserIds(op, pendingOf(ids)); // A patch that re-homes standing onto a DIFFERENT user (a `user_id` // rewrite) can put someone in `after` who was not in `before`, so the // survivors' ban state is re-read rather than intersected with the @@ -1676,32 +1605,6 @@ export function registerLastAdminGuard( await enforceStanding('grant-delete', USER_PERMISSION_SET, ctx.input); }; - /** - * [#6084] The fourth table. `resolveAdminUserIds` resolves "who is a platform - * admin" by looking the permission set up BY NAME, so renaming that row takes - * the standing away from everyone holding a grant to it, in one write, with - * no identity table touched at all. - * - * Only a payload that touches `name` can move the enumeration - * (PERMISSION_SET_STANDING_KEYS), which is what keeps every projection pass, - * every `os meta resync` and every Setup edit — none of which write `name` — - * free of any read. The data door refuses renames on its own (ADR-0094), and - * that is not this guard's coverage: this one holds for the engine-level and - * system-context writes that never pass the data door. - */ - const guardPermissionSetUpdate = async (rawCtx: unknown): Promise => { - const ctx = ctxOf(rawCtx); - if (ctx.object !== SystemObjectName.PERMISSION_SET) return; - const data = (ctx.input?.data ?? {}) as Record; - if (!touchesAny(data, PERMISSION_SET_STANDING_KEYS)) return; - await enforceStanding( - 'permission-set-update', - SystemObjectName.PERMISSION_SET, - ctx.input, - data, - ); - }; - /** * [#11663 L2] The FIFTH write shape: an ordinary `sys_user` profile write * that moves the row off the deployment's declared administrator list @@ -1724,30 +1627,34 @@ export function registerLastAdminGuard( }; /** - * [ADR-0131 D3, ADR-0126 §4] The ledger half: refuse a write that switches - * `admin_full_access` off ({@link ACTIVATION_LEDGER_STANDING_KEYS}). + * [ADR-0131 D3, ADR-0126 §4] The ledger half: a write that switches + * `admin_full_access` off — an update of its row, an update moving another + * row onto that pair, or an insert — un-makes every grant-anchored platform + * administrator, so it is judged like every other standing write and refused + * only when it would leave nobody who can sign in. A payload touching no + * standing key ({@link ACTIVATION_LEDGER_STANDING_KEYS}) costs no reads. */ - const guardActivationLedgerWrite = async (rawCtx: unknown): Promise => { + const guardActivationLedgerUpdate = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); if (ctx.object !== METADATA_ACTIVATION) return; const data = (ctx.input?.data ?? {}) as Record; if (!touchesAny(data, ACTIVATION_LEDGER_STANDING_KEYS)) return; - await refuseLedgerSwitchingAdminOff(engine, data, ctx.input?.id); + await enforceStanding('activation-update', METADATA_ACTIVATION, ctx.input, data); }; - const guardPermissionSetDelete = async (rawCtx: unknown): Promise => { + const guardActivationLedgerInsert = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); - if (ctx.object !== SystemObjectName.PERMISSION_SET) return; - // No payload to pre-filter on, and the same reasoning as the other delete - // halves: removing ANY permission-set row is judged, and the simulation - // answers "not `admin_full_access`, nothing changes" without a refusal for - // every row that is not the one the enumeration reads. - await enforceStanding('permission-set-delete', SystemObjectName.PERMISSION_SET, ctx.input); + if (ctx.object !== METADATA_ACTIVATION) return; + const data = (ctx.input?.data ?? {}) as Record; + // Only a row that is OFF can take standing away: absence means active, so + // an inserted row switched on changes nothing. + if (!(data.active === false || data.active === 0)) return; + await enforceStanding('activation-insert', METADATA_ACTIVATION, ctx.input, undefined, data); }; // Priority 20: AFTER the ADR-0092 identity write guard's checks (10), before // default-priority hooks (100) spend work on a write this may refuse. The - // four standing hooks (#5978) and the two permission-set hooks (#6084) are + // four standing hooks (#5978) and the two activation-ledger hooks (#6084) are // registered in exactly the shape the two `sys_user` hooks established — same // event names, same priority, same `packageId`, only the `object` filter // differs — so the whole invariant binds and unbinds as one package. @@ -1786,22 +1693,12 @@ export function registerLastAdminGuard( priority: 20, packageId, }); - engine.registerHook('beforeUpdate', guardPermissionSetUpdate, { - object: SystemObjectName.PERMISSION_SET, - priority: 20, - packageId, - }); - engine.registerHook('beforeDelete', guardPermissionSetDelete, { - object: SystemObjectName.PERMISSION_SET, - priority: 20, - packageId, - }); - engine.registerHook('beforeInsert', guardActivationLedgerWrite, { + engine.registerHook('beforeInsert', guardActivationLedgerInsert, { object: METADATA_ACTIVATION, priority: 20, packageId, }); - engine.registerHook('beforeUpdate', guardActivationLedgerWrite, { + engine.registerHook('beforeUpdate', guardActivationLedgerUpdate, { object: METADATA_ACTIVATION, priority: 20, packageId, @@ -1810,8 +1707,7 @@ export function registerLastAdminGuard( logger?.info( '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + - 'sys_user_permission_set (standing revocation), and sys_permission_set ' + - '(the admin_full_access row every platform admin is derived from), and sys_metadata_activation ' + + 'sys_user_permission_set (standing revocation), and sys_metadata_activation ' + '(switching admin_full_access off) — ADR-0135 D5.2', ); } diff --git a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts index 68bdfacb179..92f389759c4 100644 --- a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts @@ -128,7 +128,7 @@ describe('[#8734] standing-key lists correspond to what resolveAuthzContext read expect(STANDING_KEYS_BY_TABLE.sys_member).toBe(MEMBER_STANDING_KEYS); expect(STANDING_KEYS_BY_TABLE.sys_user_permission_set).toBe(GRANT_STANDING_KEYS); // [ADR-0131 D3] The resolver reads deactivation from the activation ledger, - // no longer from the `sys_permission_set` row (whose hooks stay registered). + // no longer from the `sys_permission_set` row, which the guard no longer judges. expect(STANDING_KEYS_BY_TABLE.sys_metadata_activation).toBe(ACTIVATION_LEDGER_STANDING_KEYS); }); }); diff --git a/packages/plugins/plugin-auth/src/logger-receiver-detach.test.ts b/packages/plugins/plugin-auth/src/logger-receiver-detach.test.ts index 2e2258c2eff..45ccd602c67 100644 --- a/packages/plugins/plugin-auth/src/logger-receiver-detach.test.ts +++ b/packages/plugins/plugin-auth/src/logger-receiver-detach.test.ts @@ -186,14 +186,14 @@ describe('[#12773] ① the audience gate reports its refusal instead of crashing // second throw), which the transport surfaced as `500 null`. expect(verdict).toBeDefined(); expect(verdict?.error).toBe(AUDIENCE_CONFIG_ERROR); - expect(verdict?.errorDescription).toMatch(/cannot be resolved in sys_permission_set/); + expect(verdict?.errorDescription).toMatch(/cannot be resolved/); // …and the operator's log carries the same verdict. const errors = logger.at('error'); expect(errors).toHaveLength(1); expect(errors[0].message).toContain('[audience]'); expect(errors[0].message).toContain("'ops_self_serve'"); - expect(errors[0].message).toMatch(/cannot be resolved in sys_permission_set/); + expect(errors[0].message).toMatch(/cannot be resolved/); }); it('② falls back to the guaranteed warn channel when the host ships no error channel', async () => { @@ -203,7 +203,7 @@ describe('[#12773] ① the audience gate reports its refusal instead of crashing expect(verdict?.error).toBe(AUDIENCE_CONFIG_ERROR); const warnings = logger.at('warn'); expect(warnings).toHaveLength(1); - expect(warnings[0].message).toMatch(/cannot be resolved in sys_permission_set/); + expect(warnings[0].message).toMatch(/cannot be resolved/); }); }); diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index 0b56f2e363a..7c03510f889 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -75,6 +75,7 @@ import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin- import { registerIdentityWriteGuard, registerManagedUpdateWhitelist } from './identity-write-guard.js'; import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; import { SCIM_DEACTIVATION_BAN_REASON } from './user-ban-write.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -179,6 +180,9 @@ async function boot(): Promise { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); + // [ADR-0131 D3/D4] `admin_full_access` is a catalog definition — what the + // break-glass guard, like the resolver, resolves a grant's set name to. + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); // The two engine guards `auth-plugin.ts` registers at `kernel:ready`, in // the same order (ADR-0092 at priority 10, break-glass at 20). diff --git a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts index cbac6198ddf..c71a42d90f7 100644 --- a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts @@ -68,6 +68,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AuthManager } from './auth-manager.js'; import { authIdentityObjects } from './manifest.js'; import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -154,7 +155,9 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-security'); engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-security'); await engine.syncSchemas(); - return engine; + // [ADR-0131 D3/D4] The declared self-registration set is a catalog definition + // — what the admission asks before it admits. + return bindTestSecurityCatalog(engine, { permissions: [{ name: 'member_default' }] }); } const SYSTEM = { context: { isSystem: true } } as never; diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 22fe52798d1..c7929c510e5 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -17,11 +17,9 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; // {@link SharingRuleService.hasPlatformAuthority}, and reading it as authority // became an escalation channel once `positions[]` started carrying ADR-0057 D4 // `sys_user_position` names; that predicate now reads the ADR-0095 rung. -// [commit 04d03c3a0] The ONE predicate for `sys_position.active` / `sys_permission_set.active` -// (#8613). Reused rather than re-spelled: two notions of "is this row active" -// — one honouring the 1/0 and 'false' storage shapes, one not — is how the -// enforcement hole this closes gets re-opened one seam over. -import { isRowActive } from '@objectstack/core'; +// [ADR-0131 D3, ADR-0126 §4] The activation ledger a position's deactivation is +// read from — the table the authorization resolver reads it from, by name. +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; // [#14754] The engine's organization refusal for a system write on a // tenant-scoped object (#8844). The runtime check is a `code` compare, which is // the convention that refusal itself documents ("a caller that catches it @@ -203,7 +201,7 @@ export interface SharingRuleServiceOptions { * down, discarded with the pass. */ type RuleEvaluationPass = { - /** `sys_position.active` verdicts, keyed `${organization}::${name}`. */ + /** Position activation verdicts (the activation ledger), keyed by position name. */ positionActive?: Map; }; @@ -1449,7 +1447,7 @@ export class SharingRuleService implements ISharingRuleService { // expansion, not after it, so the rule's desired grant set is empty and // the reconcilers' existing revoke-the-remainder branches retract what it // already materialised. See {@link positionConfersAccess}. - if (!(await this.positionConfersAccess(rule.recipient_id, rule.organization_id ?? null, pass))) { + if (!(await this.positionConfersAccess(rule.recipient_id, pass))) { return []; } // ADR-0090 D3 — positions are flat; expand holders via the platform @@ -1537,8 +1535,8 @@ export class SharingRuleService implements ISharingRuleService { } /** - * [commit 04d03c3a0] Does `positionName` still CONFER access in this rule's - * organization? Memoised for the pass; the extra read is accepted. + * [commit 04d03c3a0] Does `positionName` still CONFER access? Memoised for + * the pass; the extra read is accepted. * * Maintainer ruling, 2026-08-15, verbatim: * @@ -1565,79 +1563,59 @@ export class SharingRuleService implements ISharingRuleService { */ private async positionConfersAccess( positionName: string, - organizationId: string | null, pass: RuleEvaluationPass, ): Promise { if (!positionName) return true; // nothing to read; the expansion answers [] anyway const memo = (pass.positionActive ??= new Map()); - const key = `${organizationId ?? '*'}::${positionName}`; - const seen = memo.get(key); + const seen = memo.get(positionName); if (seen !== undefined) return seen; - const verdict = await this.readPositionActive(positionName, organizationId); - memo.set(key, verdict); + const verdict = await this.readPositionActive(positionName); + memo.set(positionName, verdict); return verdict; } /** - * [commit 04d03c3a0] The catalogue verdict for one position name. Three fallbacks, each - * of them a way this could otherwise have become a silent mass revocation: - * - * 1. **A name with no `sys_position` row is untouched.** Position names - * reach a rule through `sys_member.role` too (ADR-0057 D4's transition - * source), and those have no catalogue row at all — the same names #8613 - * leaves alone, because a name with no row has no flag to read. Note - * `isRowActive(undefined)` is `false`, so "no row" must never be handed - * to the predicate: only a row that really reads deactivated revokes. - * 2. **The row is chosen per ORGANIZATION.** `sys_position.name` is unique - * per organization (#8468), so a same-named row in another tenant is a - * DIFFERENT position; reading the flag off it would export one admin's - * deactivation into every other tenant. Platform-seeded built-ins - * (`bootstrapBuiltinRoles`, `organization_id` null) are the fallback for - * an organization that has no row of its own. - * 3. **A failed read still grants.** On a minimal stack `sys_position` may - * not exist, and a query failure is indistinguishable from that here. - * Revoking every position share because a read threw is exactly the mass - * revocation `isRowActive`'s "absent means active" rule exists to avoid. - * - * Within one scope the verdict is fail-CLOSED (any row that reads - * deactivated wins), matching `resolveAuthzContext`'s treatment of duplicate - * rows; the unique index means that case is legacy data only. + * [ADR-0131 D3, ADR-0126 §4] Whether the activation ledger leaves + * `positionName` switched on — the one place a position's deactivation lives + * (ADR-0126 §3 regime C, as ADR-0131 D6 amends it), read exactly as the + * authorization resolver reads it (`readDisabledCatalogNames`, + * `@objectstack/core`): a `position` row of that name whose `active` reads + * false (a driver `0` included) switches it off; no row means ACTIVE. The + * `sys_position` row's `active` is not read — the resolver reads no catalog + * row, so a flag there switches nothing. + * + * 1. **A name with no ledger row is untouched.** Position names reach a + * rule through `sys_member.role` too (ADR-0057 D4's transition source), + * and a name no catalog holds has nothing to switch off — the same names + * the resolver leaves alone. Only a row that really reads off revokes. + * 2. **The verdict is deployment-wide.** The ledger carries no tenant + * column, so a switched-off position is off in every organization, as it + * is for the resolver; the rule's organization does not choose a row. + * 3. **A failed read still grants.** A composition that registers no ledger + * object is not read at all; on one whose ledger table was never + * provisioned a query failure is indistinguishable from that here. Revoking every position share because a read threw is + * the mass revocation "absence means active" exists to avoid. */ - private async readPositionActive(positionName: string, organizationId: string | null): Promise { - if (organizationId) { - const own = await this.readPositionRows({ name: positionName, organization_id: organizationId }); - if (own === null) return true; - if (own.length > 0) return !own.some((r) => !isRowActive(r)); - } - const all = await this.readPositionRows({ name: positionName }); - if (all === null) return true; - const platformSeeded = all.filter((r) => (r as any).organization_id == null); - if (platformSeeded.length === 0) return true; - return !platformSeeded.some((r) => !isRowActive(r)); - } - - /** - * `sys_position` rows for one filter, or `null` when the read itself failed - * (see {@link readPositionActive} fallback 3). The `active` flag is judged in - * MEMORY, never pushed into a driver `where`: SQLite stores booleans as 1/0, - * a JSON column can hand back `'false'`, and a predicate `where` would also - * drop the NULL rows that mean "active" (row-active.ts). - */ - private async readPositionRows(filter: Record): Promise { + private async readPositionActive(positionName: string): Promise { + // A composition that registers no ledger object holds no row: the read is + // not issued, as the resolver's own `ledgerUnregistered` check decides. + const registry = (this.engine as { registry?: { getObject?: (name: string) => unknown } }).registry; + if (typeof registry?.getObject === 'function' && !registry.getObject(METADATA_ACTIVATION_TABLE)) return true; try { - const rows = await this.engine.find('sys_position', { - filter, - fields: ['id', 'name', 'active', 'organization_id'], - limit: 200, + const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, { + filter: { metadata_type: 'position', name: positionName }, + fields: ['metadata_type', 'name', 'active'], + limit: 1, context: SYSTEM_CTX, }); - return Array.isArray(rows) ? rows : []; + const row = Array.isArray(rows) ? (rows[0] as { active?: unknown } | undefined) : undefined; + return !(row !== undefined && (row.active === false || row.active === 0)); } catch (err: any) { this.logger?.warn?.( - '[sharing-rule] sys_position read failed — position treated as ACTIVE for this pass', - { position: filter.name, error: err?.message }, + `[sharing-rule] ${METADATA_ACTIVATION_TABLE} read failed — position treated as ACTIVE for this pass`, + { position: positionName, error: err?.message }, ); - return null; + return true; } } diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 57abaa031f8..2290ed042d8 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -1866,21 +1866,20 @@ describe('[#8158] a non-system caller with NO organization does not get the syst }); // --------------------------------------------------------------------------- -// commit 04d03c3a0 — a DEACTIVATED `sys_position` confers no sharing-rule shares +// commit 04d03c3a0 — a DEACTIVATED position confers no sharing-rule shares // // Maintainer ruling, 2026-08-15, the recitable line: // // > Access-conferring paths filter deactivated positions; addressing paths // > do not. // -// #8613 enforced `sys_position.active` at the authorization DERIVATION seam -// (`resolveAuthzContext`), where a deactivated position stops carrying its -// permission sets. A sharing rule reaches users by a second road that never -// passes that seam — `expandRecipient` → `PositionGraphService` — so a rule -// sharing records with `cfo` kept sharing them after `cfo` was deactivated, -// while the `deactivate_position` dialog promises, unqualified, that "the -// position stops granting permissions until re-activated". A record share is -// access, so the promise covers it. +// The authorization DERIVATION seam drops a deactivated position from what it +// grants. A sharing rule reaches users by a second road that never passes that +// seam — `expandRecipient` → `PositionGraphService` — so the call site drops a +// deactivated position itself. [ADR-0131 D3, ADR-0126 §4] Deactivation lives in +// the activation ledger (`sys_metadata_activation`, one row per +// `(metadata_type, name)`, deployment-wide), read as the resolver reads it; the +// `sys_position` row's `active` switches nothing. // // The tests below pin BOTH directions the ruling names, because the boundary // is the part most easily lost in a later refactor: @@ -1895,7 +1894,7 @@ describe('[#8158] a non-system caller with NO organization does not get the syst // permitted). Moving this filter down into the helper would take those // with it — which is exactly what the pin in ② fails on. // --------------------------------------------------------------------------- -describe('#8710 — a deactivated sys_position confers no sharing-rule shares', () => { +describe('#8710 — a deactivated position confers no sharing-rule shares', () => { let engine: ReturnType; let sharing: SharingService; let rules: SharingRuleService; @@ -1911,8 +1910,12 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', const shareRecipients = () => (engine._tables.sys_record_share ?? []).map((r: Row) => String(r.recipient_id)).sort(); - /** The catalogue row for `cfo` in org1 — the flag under test. */ - const cfoRow = () => (engine._tables.sys_position ?? []).find((r: Row) => r.id === 'pos_cfo')!; + /** Switch `cfo` in the activation ledger — the flag under test. */ + const switchCfo = (active: unknown, metadataType = 'position') => { + engine._tables.sys_metadata_activation = [ + { id: `act_${metadataType}_cfo`, metadata_type: metadataType, name: 'cfo', package_id: '', active }, + ]; + }; beforeEach(() => { engine = makeEngine(); @@ -1928,14 +1931,15 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', engine._tables.sys_position = [ { id: 'pos_cfo', name: 'cfo', organization_id: 'org1', active: true }, ]; + engine._tables.sys_metadata_activation = []; sharing = new SharingService({ engine: engine as any }); rules = new SharingRuleService({ engine: engine as any, sharing }); }); - // ── ① the change: an access-conferring path stops at a deactivated row ── + // ── ① the change: an access-conferring path stops at a switched-off position ── - it('shares nothing once the position is deactivated', async () => { - cfoRow().active = false; + it('shares nothing once the position is switched off in the ledger', async () => { + switchCfo(false); const rule = await rules.defineRule(RULE, SYS); const res = await rules.evaluateRule(rule.id, SYS); expect(res.expandedUsers).toBe(0); @@ -1943,7 +1947,7 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', expect(shareRecipients()).toEqual([]); }); - it('REVOKES the shares it already materialised when the position is deactivated', async () => { + it('REVOKES the shares it already materialised when the position is switched off', async () => { // Deactivation has to retract, not merely stop granting: the admin's // mental model is "the position stops granting permissions", and a share // that survives until the next authoring edit is the leak in slow motion. @@ -1951,7 +1955,7 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', expect((await rules.evaluateRule(rule.id, SYS)).grantsCreated).toBe(1); expect(shareRecipients()).toEqual(['u_cfo']); - cfoRow().active = false; + switchCfo(false); const res = await rules.evaluateRule(rule.id, SYS); expect(res.grantsRevoked).toBe(1); expect(shareRecipients()).toEqual([]); @@ -1965,22 +1969,19 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', await rules.evaluateRule(rule.id, SYS); expect(shareRecipients()).toEqual(['u_cfo']); - cfoRow().active = false; + switchCfo(false); expect(await rules.revokeRuleGrantsForRetiredRecipients(await rules.getRule(rule.id, SYS) as any)).toBe(1); expect(shareRecipients()).toEqual([]); }); - it('reads "deactivated" with the SHARED predicate — every driver spelling of false', async () => { - // `isRowActive` (@objectstack/core) is the one definition of activeness - // #8613 established; SQLite hands back 1/0 and a JSON column can hand back - // 'false', so `row.active === false` would miss the deactivated row on the - // primary driver. Two notions of "is this row active" is how this class of - // defect gets made — hence the shared predicate, pinned here by its own - // stored spellings. - for (const stored of [false, 0, '0', 'false']) { + it('reads the ledger as the resolver does — a driver 0 is off, so is false', async () => { + // SQLite/libsql round-trip booleans as 0/1; a `=== false` test alone would + // read a switched-off position as on. The resolver's reading is + // `active === false || active === 0`, and the two readers must agree. + for (const stored of [false, 0]) { engine._tables.sys_record_share = []; engine._tables.sys_sharing_rule = []; - cfoRow().active = stored; + switchCfo(stored); const rule = await rules.defineRule(RULE, SYS); expect(await rules.evaluateRule(rule.id, SYS), `active=${JSON.stringify(stored)}`) .toMatchObject({ expandedUsers: 0, grantsCreated: 0 }); @@ -1990,28 +1991,26 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', // ── ② the anti-vacuity + fail-open controls: what must NOT change ────── - it('an ACTIVE position shares exactly as before', async () => { + it('an ACTIVE position shares exactly as before — a ledger row switched ON included', async () => { + switchCfo(true); const rule = await rules.defineRule(RULE, SYS); expect(await rules.evaluateRule(rule.id, SYS)).toMatchObject({ expandedUsers: 1, grantsCreated: 1 }); expect(shareRecipients()).toEqual(['u_cfo']); }); - it('an ABSENT `active` column still grants — absent means active, never a mass revocation', async () => { - // Rows predating the column, arriving through a migration, or projected - // without it carry no value at all. Requiring `true` would revoke every - // such rule's shares the day this lands (row-active.ts, reason 1). - delete (cfoRow() as any).active; + it('the sys_position row\'s active switches nothing — only the ledger does', async () => { + // [ADR-0131 D3] The resolver reads no catalog row, so a row flag is not a + // deactivation; reading it here would make the two readers disagree. + (engine._tables.sys_position[0] as Row).active = false; const rule = await rules.defineRule(RULE, SYS); expect(await rules.evaluateRule(rule.id, SYS)).toMatchObject({ expandedUsers: 1, grantsCreated: 1 }); expect(shareRecipients()).toEqual(['u_cfo']); }); - it('a name with NO catalogue row at all still grants — there is no flag to read', async () => { + it('a name with NO ledger row still grants — absent means active, never a mass revocation', async () => { // The membership-derived names (`sys_member.role`, ADR-0057 D4's - // transition source) have no `sys_position` row, and #8613 leaves exactly - // those untouched. `isRowActive(undefined)` is FALSE, so a fix that piped - // "no row" straight into the predicate would silently revoke every - // membership-derived position share. + // transition source) are in no catalog and carry no ledger row; they are + // left untouched, as the resolver leaves them. engine._tables.sys_position = []; engine._tables.sys_member = [{ id: 'm1', organization_id: 'org1', user_id: 'u_role', role: 'cfo' }]; const rule = await rules.defineRule(RULE, SYS); @@ -2019,54 +2018,77 @@ describe('#8710 — a deactivated sys_position confers no sharing-rule shares', expect(shareRecipients()).toEqual(['u_cfo', 'u_role']); }); - it('another org deactivating the SAME name does not stop this org\'s shares', async () => { - // `sys_position.name` is unique per ORGANIZATION (#8468), so two tenants - // legitimately hold a `cfo` row. Reading the flag off the wrong tenant's - // row would export one admin's deactivation into every other tenant. - engine._tables.sys_position.push({ id: 'pos_cfo_org2', name: 'cfo', organization_id: 'org2', active: false }); + it('another TYPE switched off under the same name does not stop the position', async () => { + // The ledger is shared by every activation type; only a `position` row + // speaks for a position (a permission set is commonly named like one). + switchCfo(false, 'permission'); const rule = await rules.defineRule(RULE, SYS); expect(await rules.evaluateRule(rule.id, SYS)).toMatchObject({ expandedUsers: 1, grantsCreated: 1 }); expect(shareRecipients()).toEqual(['u_cfo']); }); + it('a ledger read that fails still grants — never a mass revocation', async () => { + const find = engine.find.bind(engine); + (engine as any).find = async (object: string, opts?: any) => { + if (object === 'sys_metadata_activation') throw new Error('ledger unreachable'); + return find(object, opts); + }; + const rule = await rules.defineRule(RULE, SYS); + expect(await rules.evaluateRule(rule.id, SYS)).toMatchObject({ expandedUsers: 1, grantsCreated: 1 }); + }); + + it('a composition that registers no ledger object issues no ledger read, and the position still shares', async () => { + const find = engine.find.bind(engine); + let ledgerReads = 0; + (engine as any).find = async (object: string, opts?: any) => { + if (object === 'sys_metadata_activation') ledgerReads += 1; + return find(object, opts); + }; + (engine as any).registry = { getObject: (name: string) => (name === 'sys_metadata_activation' ? undefined : { name }) }; + switchCfo(false); // a row the composition does not serve + const rule = await rules.defineRule(RULE, SYS); + expect(await rules.evaluateRule(rule.id, SYS)).toMatchObject({ expandedUsers: 1, grantsCreated: 1 }); + expect(ledgerReads).toBe(0); + }); + it('the ADDRESSING primitive stays a RAW directory read — the filter is the call site\'s', async () => { // ⛔ The load-bearing negative pin. `PositionGraphService` answers "who // holds position P" for every consumer of the graph; approval ROUTING and // the plugin-security write gates read that answer and MUST keep seeing // the deactivated position's holders (filtering there is fail-open / - // access-widening respectively, #8613's carve-outs, reaffirmed by the - // 2026-08-15 ruling). An ablation that moves this PR's filter down into - // `expandPositionUsers` turns this red. - cfoRow().active = false; + // access-widening respectively, reaffirmed by the 2026-08-15 ruling). An + // ablation that moves this filter down into `expandPositionUsers` turns + // this red. + switchCfo(false); const graph = new PositionGraphService({ engine: engine as any, organizationId: 'org1' }); expect(await graph.expandPositionUsers('cfo')).toEqual(['u_cfo']); }); // ── the cache: one read per position per pass, and never one pass longer ── - it('reads the catalogue once per position per evaluator pass, and re-reads on the next', async () => { + it('reads the ledger once per position per evaluator pass, and re-reads on the next', async () => { // The ruling accepts the extra read but asks for it cached per evaluator // pass. Both halves are the assertion: within one pass two rules naming // the same position pay one read; ACROSS passes the verdict is re-read, or // a deactivation would take effect late — its own defect. const find = engine.find.bind(engine); - let catalogueReads = 0; + let ledgerReads = 0; (engine as any).find = async (object: string, opts?: any) => { - if (object === 'sys_position') catalogueReads += 1; + if (object === 'sys_metadata_activation') ledgerReads += 1; return find(object, opts); }; await rules.defineRule(RULE, SYS); await rules.defineRule({ ...RULE, name: 'cfo_won_deals_2', label: 'CFO — won deals (2)' }, SYS); await rules.evaluateAllForRecord('opportunity', 'opp1', SYS); - expect(catalogueReads).toBe(1); + expect(ledgerReads).toBe(1); // One share row, not two: `grant` upserts per (record, recipient), so the // second rule re-grants the row the first one created. expect(shareRecipients()).toEqual(['u_cfo']); - cfoRow().active = false; + switchCfo(false); await rules.evaluateAllForRecord('opportunity', 'opp1', SYS); - expect(catalogueReads).toBe(2); + expect(ledgerReads).toBe(2); expect(shareRecipients()).toEqual([]); }); });