From 7b518122a40608d27dc052d05931b14c4816bb55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:31:05 +0000 Subject: [PATCH 01/38] wip: PositionSchema.permissionSets; resolver reads positions, sets and bodies from the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- packages/core/src/security/index.ts | 2 + .../src/security/resolve-authz-context.ts | 273 ++++++++++-------- .../core/src/security/security-catalog.ts | 52 +++- packages/spec/src/identity/position.test.ts | 21 +- packages/spec/src/identity/position.zod.ts | 39 ++- packages/spec/src/security/permission.zod.ts | 4 +- 6 files changed, 254 insertions(+), 137 deletions(-) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index ebc327acce3..befc907abc1 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -235,6 +235,8 @@ export { isRowActive, type ActivatableRow } from './row-active.js'; // effect: the row `active` flag above stays the authority for that. export { createSecurityCatalogReader, + bindSecurityCatalogReader, + securityCatalogReaderOf, type SecurityCatalogType, type SecurityCatalogSourceName, type SecurityCatalogRegistry, diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 561b08dadda..a121c2ce154 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -70,6 +70,7 @@ import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-m import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; import { isRowActive } from './row-active.js'; +import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { @@ -141,10 +142,6 @@ export interface ResolveAuthzInput { tenancyPosture?: TenancyPosture; } -function safeJsonParse(s: string, fallback: T): T { - try { return JSON.parse(s) as T; } catch { return fallback; } -} - /** * [#15256 — maintainer ruling 2026-09-04, decision 2A] Say a posture-conditional * API-key refusal OUT LOUD, on the SERVER side, where the refusal is decided. @@ -742,69 +739,98 @@ function grantOrganizationOf(row: unknown): string | null { } /** - * [ADR-0131 D4] The `sys_permission_set` row each user grant holds, found BY - * NAME: the row of the grant's OWN organization, else the organization-less - * row. A set row of another organization is never the answer, whatever the - * grant's `permission_set_id` points at. + * [ADR-0049] The `sys_permission_set` rows that carry the `active` flag of the + * sets `names` names, found BY NAME: the active organization's own row first, + * else the organization-less row. The row is read for its flag and for nothing + * else — the set's body is its catalog definition ({@link catalogSetBody}), + * and no row decides whether a set exists (ADR-0131 D3/D4). * - * The catalog is still materialized per organization, so one name can carry - * several rows. A row of an organization applies to a grant of that same - * organization, an organization-less row to every grant — the grant rule - * {@link grantAppliesInTenant} asks of the grant itself, asked of the set row. - * So an organization-less grant reads organization-less rows only, and an - * organization's grant reads its organization's row first. + * The catalog rows are still materialized per organization, so one name can + * carry several rows. A row of an organization applies to that organization + * only, an organization-less row everywhere — the grant rule + * {@link grantAppliesInTenant} asks of a grant, asked of the set row. * - * Two reads, issued together: the organization-less rows for every name, and — - * only when an organization's grant is among `grants` — that organization's - * rows, with the organization threaded into the context so the driver's tenant - * scope (`applyTenantScope`, the one governed spelling of the wall) decides - * what is visible. `grants` are the ones {@link grantAppliesInTenant} already - * kept, so the only organization among them is `tenantId`. + * Two reads, issued together: the organization-less rows for every name in + * `names`, and — for the names in `ownNames`, when there is an active + * organization — that organization's rows, with the organization threaded into + * the context so the driver's tenant scope (`applyTenantScope`, the one + * governed spelling of the wall) decides what is visible. * - * Which row a name resolves to is decided before the ADR-0049 `active` flag is - * read: an organization's own row that is deactivated is that grant's answer, - * and it confers nothing — it does not fall through to the organization-less - * row. + * Which row answers is decided before its flag is read: an organization's own + * row that is deactivated is that organization's answer, and it does not fall + * through to the organization-less row. * * A read that fails throws (`tryFind`'s `AuthzStoreUnavailableError`). */ -async function readGrantSetRowsByName( +async function readSetActivationRows( ql: any, - grants: readonly unknown[], + names: ReadonlySet, + ownNames: ReadonlySet, tenantId: string | undefined, -): Promise<(grant: unknown) => any | undefined> { - const allNames = new Set(); - const ownNames = new Set(); - for (const grant of grants) { - const name = grantSetNameOf(grant); - if (!name) continue; - allNames.add(name); - const organizationId = grantOrganizationOf(grant); - if (organizationId !== null && organizationId === tenantId) ownNames.add(name); - } - if (allNames.size === 0) return () => undefined; - +): Promise<{ organizationLess: Map; own: Map }> { + const organizationLess = new Map(); + const own = new Map(); + if (names.size === 0) return { organizationLess, own }; const [organizationLessRows, ownRows] = await Promise.all([ - tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(allNames) }, organization_id: null }, 500), + tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(names) }, organization_id: null }, 500), tenantId && ownNames.size > 0 ? tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(ownNames) } }, 500, tenantId) : Promise.resolve([] as any[]), ]); - const organizationLess = new Map(); for (const row of organizationLessRows) { if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== null) continue; if (!organizationLess.has(row.name)) organizationLess.set(row.name, row); } - const own = new Map(); for (const row of ownRows) { if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== tenantId) continue; if (!own.has(row.name)) own.set(row.name, row); } - return (grant: unknown) => { - const name = grantSetNameOf(grant); - if (!name) return undefined; - const organizationId = grantOrganizationOf(grant); - return (organizationId !== null ? own.get(name) : undefined) ?? organizationLess.get(name); + return { organizationLess, own }; +} + +/** + * A set is in effect unless the row that answers for it says otherwise: no row + * carries no flag to read, the way a position name with no `sys_position` row + * is untouched at §6a. Existence is the catalog's question, never the row's. + */ +function activationAllows(row: unknown): boolean { + return row === undefined || isRowActive(row as any); +} + +/** + * [ADR-0131 D3/D4] The permission sets a position definition names + * (`PositionSchema.permissionSets`), in declared order — the position → set + * binding, read from the definition and from no junction row. + */ +function positionSetNamesOf(definition: Readonly> | undefined): string[] { + const value = definition?.permissionSets; + if (!Array.isArray(value)) return []; + return value.filter((n): n is string => typeof n === 'string' && n !== ''); +} + +/** What §6b derives from one held set: its catalog body, read from the definition. */ +interface CatalogSetBody { + name: string; + systemPermissions: unknown; + tabPermissions: unknown; +} + +/** + * [ADR-0131 D3/D4] The body of the permission set `name` names, read from its + * catalog definition — or `undefined` when the catalog holds no set of that + * name (or no catalog is bound), which confers nothing. + */ +async function catalogSetBody( + catalog: SecurityCatalogReader | undefined, + name: string, +): Promise { + if (!catalog) return undefined; + const entry = await catalog.resolve('permission', name); + if (!entry) return undefined; + return { + name: entry.name, + systemPermissions: entry.definition.systemPermissions, + tabPermissions: entry.definition.tabPermissions, }; } @@ -813,9 +839,10 @@ async function readGrantSetRowsByName( * * Given a KNOWN user id, aggregate the authorization grants that user holds: * org-admin positions (`sys_member`), platform-RBAC positions - * (`sys_user_position`), user- and position-bound permission sets - * (`sys_user_permission_set` / `sys_position_permission_set` → - * `sys_permission_set`), the derived `platform_admin` built-in + posture rung, + * (`sys_user_position`), user- and position-bound permission sets (the + * `sys_user_permission_set` grants, and the sets each held position's catalog + * definition names — ADR-0131 D3/D4), the derived `platform_admin` built-in + + * posture rung, * fellow-org peers for identity-table RLS, and the env-side `ai_seat`. * * Factored out of `resolveAuthzContext` so a surface that already knows WHO the @@ -1027,23 +1054,36 @@ export async function resolveUserAuthzGrants( // Rows outside their validity window are dropped BEFORE any derivation, so // an expired admin_full_access grant cannot yield platform_admin either. // - // [ADR-0131 D4] A grant names its set BY NAME (`permission_set`), and the - // set row is found by that name — the grant's own organization's row, else - // the organization-less one ({@link readGrantSetRowsByName}). The grant's - // `permission_set_id` is not read here: a grant that names nothing, or - // whose name resolves only to another organization's set, confers nothing - // through this resolver. Issued now, so it shares the `sys_position` read's - // leg below; awaited at §6b. + // [ADR-0131 D3/D4] A grant names its set BY NAME (`permission_set`), and + // the set is the catalog's definition of that name ({@link catalogSetBody}) + // — the environment registry the security plugin binds to this engine + // ({@link securityCatalogReaderOf}). No row decides whether a set exists or + // what it grants. The grant's `permission_set_id` is not read: a grant + // that names nothing, or a name the catalog does not hold, confers nothing. + // + // [ADR-0049] The `active` flag is the one thing still read from a row: the + // grant's own organization's row, else the organization-less one + // ({@link readSetActivationRows}). Issued now, so it shares the + // `sys_position` read's leg below; awaited at §6b. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); const applyingGrants = upsRows.filter((r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId)); - const grantSetRowsLeg = readGrantSetRowsByName(ql, applyingGrants, tenantId); + const catalog = securityCatalogReaderOf(ql); + const grantSetNames = new Set(); + const ownGrantSetNames = new Set(); + for (const grant of applyingGrants) { + const name = grantSetNameOf(grant); + if (!name) continue; + grantSetNames.add(name); + const organizationId = grantOrganizationOf(grant); + if (organizationId !== null && organizationId === tenantId) ownGrantSetNames.add(name); + } + const grantActivationLeg = readSetActivationRows(ql, grantSetNames, ownGrantSetNames, tenantId); // Its failure is observed where it is awaited (§6b); this only keeps a throw // from §6a, which runs first, from leaving the rejection unhandled. - grantSetRowsLeg.catch(() => undefined); - // Position-bound sets (§6a) are still reached through the junction's id — - // the binding relation is ADR-0131 C3's, kept until it retires. - const psIds = new Set(); + grantActivationLeg.catch(() => undefined); + // Position-bound sets (§6a), by name, in the order the held positions name them. + const boundSetNames: string[] = []; // [#11663 L5] …and under a WALLED posture it no longer is. This is the EXIT of // the migration window L4 opened: the walled half of the legacy unscoped anchor // is RETIRED, so on a walled rig platform standing is config-derived @@ -1076,8 +1116,10 @@ export async function resolveUserAuthzGrants( // "only when the user has nothing else" cliff. if (!grants.positions.includes('everyone')) grants.positions.push('everyone'); - // 6a. Position-bound permission sets (sys_position_permission_set): a position - // carries its permission sets. + // 6a. Position-bound permission sets: a position carries its permission sets + // in its definition (`PositionSchema.permissionSets`, ADR-0131 D3/D4), + // read from the catalog by the position's NAME. No junction row is read. + // A held name the catalog has no position for distributes nothing. // // [ADR-0049] A DEACTIVATED position grants nothing — the `deactivate_position` // dialog's promise ("users keep their assignment but the position stops @@ -1095,15 +1137,17 @@ export async function resolveUserAuthzGrants( // with no `sys_position` row at all (`org_owner`, a membership-derived // role) has no flag to read and is untouched. if (grants.positions.length > 0) { + // The `sys_position` row is read for its ADR-0049 `active` flag and for + // nothing else. + // // [#10103] Scoped to the CALLER's organization. `sys_position` spells // its name index `unique: 'organization'` and its rows are materialized // per organization, so several organizations hold a row named // `everyone` (and one named after every declared position). Swept by - // name alone, this read returned EVERY organization's rows, and the - // junction read below then collected another organization's bindings — - // a cross-organization grant bleed, measured reachable from one tenant's - // resolution to another tenant's `everyone` binding. It also made the - // sweep O(organizations) on a table that is read on every request. + // name alone, this read returned EVERY organization's rows — another + // organization's deactivation then decided this one's grant. It also + // made the sweep O(organizations) on a table that is read on every + // request. // // Scoped by threading the organization into the context rather than by // adding an `organization_id` predicate here: the driver's @@ -1115,10 +1159,7 @@ export async function resolveUserAuthzGrants( // // Limit raised with it: the cap has to admit this organization's rows // alongside any organization-less ones the driver's compatibility arm - // still returns, or a caller silently loses positions. Those - // organization-less rows stay REACHABLE on purpose — they are not - // reaped, and grants point at them by row id, so dropping them here - // would revoke standing access silently. + // still returns, or a caller silently loses a deactivation. // // [#20515] The same grant rule as §4 and §6 is then asked of each // position row ({@link grantAppliesInTenant}). With a tenant it is a @@ -1127,12 +1168,9 @@ export async function resolveUserAuthzGrants( // installation-wide by design, so without it every organization's // `everyone` row, and every organization's copy of a name the caller // holds (the `sys_member` role projection's `org_member`, say), fed its - // bindings into an organization-less resolution: measured on a real - // `SqlDriver` over the shipped per-organization catalog, a member - // removed from an organization kept the `manage_metadata` set that - // organization had bound to its `org_member` position. This is the - // grant rule, not a second tenant wall: it decides which organization's - // bindings APPLY, after the driver decided which rows are visible. + // row into an organization-less resolution. This is the grant rule, not + // a second tenant wall: it decides which organization's rows APPLY, + // after the driver decided which rows are visible. const positionRows = (await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId)) .filter((r) => grantAppliesInTenant(r.organization_id, tenantId)); const deactivatedNames = new Set( @@ -1141,12 +1179,12 @@ export async function resolveUserAuthzGrants( if (deactivatedNames.size > 0) { grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n)); } - const positionIds = positionRows.filter((r) => isRowActive(r)).map((r) => r.id).filter(Boolean); - if (positionIds.length > 0) { - const rpsRows = await tryFind(ql, 'sys_position_permission_set', { position_id: { $in: positionIds } }, 500); - for (const r of rpsRows) { - const id = r.permission_set_id ?? r.permissionSetId; - if (id) psIds.add(id); + if (catalog) { + for (const positionName of grants.positions) { + const entry = await catalog.resolve('position', positionName); + for (const setName of positionSetNamesOf(entry?.definition)) { + if (!boundSetNames.includes(setName)) boundSetNames.push(setName); + } } } } @@ -1154,21 +1192,31 @@ export async function resolveUserAuthzGrants( // 6b. Resolve permission-set details (names → grants.permissions; system_permissions; // tab_permissions merged by highest visibility). // - // The user grants' rows, found by name (§6), come first, in grant order; - // the position-bound rows (§6a) follow, read by the junction's id. - const setRowOfGrant = await grantSetRowsLeg; - const psRowsAll: any[] = []; - const takenRows = new Set(); - const take = (row: any): void => { - const key = row?.id ?? row; - if (takenRows.has(key)) return; - takenRows.add(key); - psRowsAll.push(row); + // The user grants' sets (§6) come first, in grant order; the + // position-bound sets (§6a) follow, in the order the positions name them. + // Each body is its catalog definition; each `active` flag is its row's — + // the active organization's own row first, else the organization-less + // one, for a position-bound set as for an organization's grant. + const [grantActivation, boundActivation] = await Promise.all([ + grantActivationLeg, + readSetActivationRows(ql, new Set(boundSetNames), tenantId ? new Set(boundSetNames) : new Set(), tenantId), + ]); + const psRowsAll: Array = []; + const takenNames = new Set(); + const take = (body: CatalogSetBody, active: boolean): void => { + if (takenNames.has(body.name)) return; + takenNames.add(body.name); + psRowsAll.push({ ...body, active }); }; for (const grant of applyingGrants) { - const row = setRowOfGrant(grant); - if (!row) continue; - take(row); + const name = grantSetNameOf(grant); + if (!name) continue; + const body = await catalogSetBody(catalog, name); + if (!body) continue; + const ownGrant = grantOrganizationOf(grant) !== null; + const row = (ownGrant ? grantActivation.own.get(name) : undefined) ?? grantActivation.organizationLess.get(name); + const active = activationAllows(row); + take(body, active); // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access // USER grant — the single source of truth (no trusted stored boolean). An // unscoped grant reads the organization-less row only, so the set it holds @@ -1179,44 +1227,41 @@ export async function resolveUserAuthzGrants( // // [#11663 L5] `legacyGrantAnchorRetired` gates the ANCHOR, not the grant: the // set's own name and capabilities are pushed below exactly as for any other - // held set, on every posture. What a walled rig stops deriving from the row is - // PLATFORM_ADMIN STANDING — the rung, the built-in `platform_admin` position - // and everything downstream of them. The row itself is untouched here; its - // ownership is ADR-0131 C3's, on the v18 line. + // held set, on every posture. What a walled rig stops deriving from the + // grant is PLATFORM_ADMIN STANDING — the rung, the built-in + // `platform_admin` position and everything downstream of them. if ( - grantOrganizationOf(grant) === null - && row.name === ADMIN_FULL_ACCESS - && isRowActive(row) + !ownGrant + && body.name === ADMIN_FULL_ACCESS + && active && !legacyGrantAnchorRetired ) { hasPlatformAdminGrant = true; } } - if (psIds.size > 0) { - for (const row of await tryFind(ql, PERMISSION_SET_CATALOG, { id: { $in: Array.from(psIds) } }, 500)) take(row); + for (const name of boundSetNames) { + const body = await catalogSetBody(catalog, name); + if (!body) continue; + take(body, activationAllows(boundActivation.own.get(name) ?? boundActivation.organizationLess.get(name))); } if (psRowsAll.length > 0) { // [ADR-0049] A DEACTIVATED permission set grants nothing — the // `deactivate_permission_set` dialog's promise ("existing assignments stay // in place but stop granting access"). Dropped BEFORE any derivation, the // same discipline the validity window gets at §6. - const psRows = psRowsAll.filter((r) => isRowActive(r)); + const psRows = psRowsAll.filter((r) => r.active); const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; const mergedTabs: Record = {}; for (const ps of psRows) { if (ps.name && !grants.permissions.includes(ps.name)) grants.permissions.push(ps.name); - const sysPerms = typeof ps.system_permissions === 'string' - ? safeJsonParse(ps.system_permissions, []) - : (ps.system_permissions ?? ps.systemPermissions); + const sysPerms = ps.systemPermissions; if (Array.isArray(sysPerms)) { for (const p of sysPerms) { if (typeof p === 'string' && !grants.systemPermissions.includes(p)) grants.systemPermissions.push(p); } } - const tabs = typeof ps.tab_permissions === 'string' - ? safeJsonParse(ps.tab_permissions, {}) - : (ps.tab_permissions ?? ps.tabPermissions); - if (tabs && typeof tabs === 'object') { + const tabs = ps.tabPermissions; + if (tabs && typeof tabs === 'object' && !Array.isArray(tabs)) { for (const [app, val] of Object.entries(tabs as Record)) { if (typeof val !== 'string' || !(val in tabRank)) continue; const cur = mergedTabs[app]; diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts index 7da2520996d..5fc24ee51be 100644 --- a/packages/core/src/security/security-catalog.ts +++ b/packages/core/src/security/security-catalog.ts @@ -10,9 +10,10 @@ * code-declared packages and by environment metadata a metadata author saved * (`sys_metadata`, hydrated into the same registry by `loadMetaFromDb`) — and * D4 makes every reference to a catalog item a NAME that "resolution reads the - * registry" for. This module is that read and nothing else. It is the seam the - * later stages of the C2 execution card switch readers onto; on its own it - * changes no grant, because nothing calls it yet. + * registry" for. This module is that read and nothing else. The authorization + * resolver (`resolve-authz-context.ts`) reads positions, their permission sets + * and the set bodies through it, from the reader the security plugin binds to + * its engine ({@link bindSecurityCatalogReader}). * * ## Two in-process readers, read in a fixed order * @@ -73,8 +74,10 @@ * caller that drops the row flag because it now reads definitions would let * a deactivated set grant again: the one widening this card must never * open. Read the flag with `isRowActive` (`row-active.ts`) as before. - * - **The position → permission-set binding.** It stays on its junction rows - * until the position definition carries it. + * - **The position → permission-set binding as a separate fact.** It is part + * of the position definition (`PositionSchema.permissionSets`, ADR-0131 + * D3/D4); the resolver reads it from the entry this module answers, and no + * junction row. * - **Organization scope.** The catalog is environment-level (ADR-0131 D3); * this read takes no organization and never filters by one. * @@ -358,3 +361,42 @@ export function createSecurityCatalogReader(sources: SecurityCatalogSources): Se }, }; } + +/** + * The reader bound to each engine. Keyed by the engine AND by its registry, so + * a caller that hands the resolver the engine, or a view of it that exposes the + * same per-engine registry, finds the same reader. + */ +const boundReaders = new WeakMap(); + +/** + * Bind the catalog read to an ObjectQL engine — the security plugin does it at + * `start()`, where it holds both the engine and the metadata service. Every + * authorization resolution over that engine then reads its catalog through + * `reader` ({@link securityCatalogReaderOf}). + * + * @returns whether anything was bound — `false` for a value that is not an + * object, which the caller reports (absence is loud). + */ +export function bindSecurityCatalogReader(engine: unknown, reader: SecurityCatalogReader): boolean { + if (!engine || typeof engine !== 'object') return false; + boundReaders.set(engine, reader); + const registry = (engine as { registry?: unknown }).registry; + if (registry && typeof registry === 'object') boundReaders.set(registry, reader); + return true; +} + +/** + * The catalog read bound to `engine`, or `undefined` when none was bound — an + * engine no security plugin started on. The resolver reads that as an empty + * catalog, exactly as it reads an engine with no permission tables + * provisioned: no position distributes a set and no set has a body, so nothing + * is granted through the catalog. It is never read as permission to grant. + */ +export function securityCatalogReaderOf(engine: unknown): SecurityCatalogReader | undefined { + if (!engine || typeof engine !== 'object') return undefined; + const own = boundReaders.get(engine); + if (own) return own; + const registry = (engine as { registry?: unknown }).registry; + return registry && typeof registry === 'object' ? boundReaders.get(registry) : undefined; +} diff --git a/packages/spec/src/identity/position.test.ts b/packages/spec/src/identity/position.test.ts index 3ce598e9d56..c29e37402c2 100644 --- a/packages/spec/src/identity/position.test.ts +++ b/packages/spec/src/identity/position.test.ts @@ -244,9 +244,7 @@ describe('unknown keys are rejected, not stripped', () => { .toContain('`notAKey`'); }); - it('points permissionSets/users/parent at the runtime binding or flatness rule', () => { - expect(unknownKeyIssue({ name: 'p', label: 'P', permissionSets: [] })!.message) - .toContain('sys_position_permission_set'); + it('points users/parent at the runtime binding or flatness rule', () => { expect(unknownKeyIssue({ name: 'p', label: 'P', users: [] })!.message) .toContain('sys_user_position'); expect(unknownKeyIssue({ name: 'p', label: 'P', parent: 'boss' })!.message) @@ -259,10 +257,25 @@ describe('unknown keys are rejected, not stripped', () => { // prescription — the migrate-meta half is the semantic entry // `position-permissions-column-retired`. const msg = unknownKeyIssue({ name: 'p', label: 'P', permissions: '["x"]' })!.message; - expect(msg).toContain('sys_position_permission_set'); + expect(msg).toContain('`permissionSets`'); expect(msg).toContain('ADR-0049'); }); + // [ADR-0131 D3/D4] `permissionSets` is the position → set binding: accepted, + // each entry a set NAME under the same grammar as a permission set's own name. + it('accepts `permissionSets` as a list of permission-set names', () => { + const parsed = PositionSchema.safeParse({ name: 'auditor', label: 'Auditor', permissionSets: ['read_all', 'audit_tools'] }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.permissionSets).toEqual(['read_all', 'audit_tools']); + }); + + it('refuses a `permissionSets` entry that is not a snake_case set name', () => { + const result = PositionSchema.safeParse({ name: 'auditor', label: 'Auditor', permissionSets: ['Read All'] }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.path.join('.') === 'permissionSets.0')).toBe(true); + expect(result.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(false); + }); + it('round-trips the ADR-0010 runtime protection envelope', () => { const parsed = PositionSchema.parse({ name: 'auditor', label: 'Auditor', diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 989e07cae48..2bbb605188f 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -11,9 +11,11 @@ import { strictObject } from '../shared/strict-object'; * * A position (岗位, "job role" in NetSuite/Workday terms) is a **named, * assignable bundle of permission sets**: users hold positions - * (`sys_user_position`), positions bind permission sets - * (`sys_position_permission_set`), and a user's capability is the union of - * every set reached that way plus direct grants. + * (`sys_user_position`), a position names the permission sets it distributes + * in {@link PositionSchema}'s `permissionSets` (ADR-0131 D3/D4: the binding is + * part of the definition, resolved by name in the environment catalog), and a + * user's capability is the union of every set reached that way plus direct + * grants. * * Positions are deliberately **flat** — no `parent`, no hierarchy. The * visibility hierarchy lives on the business-unit tree (`sys_business_unit`, @@ -48,12 +50,6 @@ export const PositionSchema = lazySchema(() => strictObject( surface: 'this position', aliases: { title: 'label' }, guidance: { - permissionSets: - '`permissionSets` is not a Position field — a position is only the named ' + - 'distribution point (ADR-0090 D3); capability arrives via runtime bindings ' + - '(`sys_position_permission_set` rows, created in Setup or by an app\'s ' + - 'kernel:ready binder). Packages SUGGEST bindings via `isDefault` on a ' + - 'permission set, never by declaring them on the position.', users: '`users` is not a Position field — assignment is a runtime binding ' + '(`sys_user_position` rows), never authored on the position (ADR-0090).', @@ -65,12 +61,11 @@ export const PositionSchema = lazySchema(() => strictObject( permissions: '`permissions` is not a Position field — there are no direct position-level ' + 'permission strings anywhere on the platform: capability reaches a position ' + - 'ONLY through permission-set bindings (`sys_position_permission_set` rows, ' + - 'created in Setup or by an app\'s kernel:ready binder). The `sys_position` ' + + 'ONLY through the permission sets it names in `permissionSets`. The `sys_position` ' + 'row column of the same name — a "JSON-serialized array of permission ' + 'strings" textarea no producer ever wrote and no runtime path ever read — ' + 'was retired under ADR-0049 enforce-or-remove. Delete the key; to ' + - 'grant capability, bind permission sets to the position instead.', + 'grant capability, name permission sets in `permissionSets` instead.', }, history: 'Until this shape was closed, these were dropped silently — the position still parsed, so the ' + @@ -92,6 +87,26 @@ export const PositionSchema = lazySchema(() => strictObject( /** Description */ description: z.string().optional(), + /** + * [ADR-0131 D3/D4] The permission sets this position distributes, each by + * its NAME. A holder of the position holds every set named here, resolved + * by name in the environment catalog (code-declared packages and environment + * metadata) — the same by-name read a user's direct grant gets. + * + * This key replaced the `sys_position_permission_set` junction as the + * position → set binding: the authorization resolver reads this list and no + * junction row. A name that resolves to no permission set confers nothing. + * A set the catalog row deactivates confers nothing either: the `active` + * flag stays on the row until it gains a definition home. + * + * The built-in `everyone` anchor (ADR-0090 D5) is declared by the platform + * with the deployment's baseline sets; a package does not name it here, it + * marks its baseline with `isDefault` on the permission set. + */ + permissionSets: z.array(SnakeCaseIdentifierSchema).optional().describe( + 'ADR-0131 D3/D4: the permission sets this position distributes, by name (resolved in the environment catalog).', + ), + /** * [ADR-0091 D3] Delegation of duty (职务代理). When true, a holder of this * position may SELF-SERVICE assign it to a delegate — time-boxed diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index bc2476ae334..c8e3643d8c1 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -792,8 +792,8 @@ export const PermissionSetSchema = lazySchema(() => strictObject( // ── Wrong-layer pointers for keys that are never authored on a set. profiles: '`profiles` is not a PermissionSet field (ADR-0090 D2: no Profile concept). ' + - 'Distribution is a runtime binding — positions bind sets to people ' + - '(`sys_position_permission_set`), never the set itself.', + 'Distribution is a position\'s job — a position names the sets it distributes ' + + 'in its own `permissionSets`, never the set itself.', roles: '`roles` is not a PermissionSet field — ObjectStack has no role hierarchy: ' + 'capability = permission sets (union-merged), distribution = positions, ' + From 8cbedf969eb08e2a63ac934a91fd1729340f9ac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:34:56 +0000 Subject: [PATCH 02/38] wip: SecurityPlugin binds the catalog read; everyone declares the baseline Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../plugin-security/src/builtin-positions.ts | 38 +++++++++++++----- .../plugin-security/src/security-plugin.ts | 40 ++++++++++++++++--- .../spec/authorable-surface/identity.json | 1 + 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 4e6064abbae..02f61b0325c 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -49,14 +49,21 @@ * * ## What a declaration here does NOT carry * - * Only identity and display: `name`, `label`, `description` — what - * `PositionSchema` declares and what the rows have always been stamped with. - * Activation (`active`), the default flag (`is_default`) and provenance - * (`managed_by`) stay on the catalog ROW; the six bind no permission set in - * their definition (the binding stays on its junction rows until the position - * definition can carry it). The sources of truth behind the identity names — - * `sys_member.role` for the `org_*` trio, the unscoped `admin_full_access` - * grant for `platform_admin` — are untouched: these remain a projection. + * Identity and display: `name`, `label`, `description` — what the rows have + * always been stamped with. Activation (`active`), the default flag + * (`is_default`) and provenance (`managed_by`) stay on the catalog ROW. The + * sources of truth behind the identity names — `sys_member.role` for the + * `org_*` trio, the unscoped `admin_full_access` grant for `platform_admin` — + * are untouched: these remain a projection. + * + * ## The one binding a declaration here carries: `everyone`'s baseline + * + * [ADR-0090 D5, ADR-0131 D3/D4] The `everyone` anchor distributes the + * deployment's baseline sets — the app's `isDefault` set composed with the + * platform's `member_default` — in its definition's `permissionSets`, which + * the authorization resolver reads. The plugin passes that list + * ({@link registerBuiltinPositions}'s `permissionSets`); no other built-in + * names a set. */ import { @@ -72,6 +79,8 @@ export interface BuiltinPositionDeclaration { readonly name: string; readonly label: string; readonly description: string; + /** The sets this built-in distributes (`PositionSchema.permissionSets`) — `everyone`'s baseline only. */ + readonly permissionSets?: readonly string[]; } /** @@ -125,14 +134,23 @@ export interface BuiltinPositionRegistry { * registration gets a fresh copy: the registry stamps provenance onto the * object it is handed, and the list above is shared. * + * `everyoneSets` is the baseline the `everyone` anchor distributes (module + * doc); an empty list declares no `permissionSets` at all. + * * @returns how many were registered — 0 when `registry` cannot register * anything, which the caller reports (absence is loud). */ -export function registerBuiltinPositions(registry: unknown, packageId: string): number { +export function registerBuiltinPositions( + registry: unknown, + packageId: string, + everyoneSets: readonly string[] = [], +): number { const target = registry as Partial | null | undefined; if (!target || typeof target.registerItem !== 'function') return 0; for (const declaration of securityBuiltinPositions) { - target.registerItem('position', { ...declaration }, 'name', packageId); + const item: Record = { ...declaration }; + if (declaration.name === EVERYONE_POSITION && everyoneSets.length > 0) item.permissionSets = [...everyoneSets]; + target.registerItem('position', item, 'name', packageId); } return securityBuiltinPositions.length; } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1b8a4366515..437c8a73f6b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -100,7 +100,7 @@ import { PLATFORM_OWNER_WALL_BYPASS_EVENT, isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader } from '@objectstack/core'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy, @@ -1784,14 +1784,42 @@ export class SecurityPlugin implements Plugin { // id — the provenance the manifest stamps on `permissions`. Here, on the // engine handle `start()` already holds, before `kernel:ready` runs the // seeders and before any request reaches the metadata door. Rows keep - // seeding from the same list (`bootstrapBuiltinRoles`); nothing here writes - // a row or moves a grant. - if (registerBuiltinPositions((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID) === 0) { + // seeding from the same list (`bootstrapBuiltinRoles`). + // + // [ADR-0090 D5, ADR-0131 D3/D4] `everyone` declares the deployment's + // baseline in its `permissionSets` — the binding the authorization resolver + // reads. A platform set carrying high-privilege bits is never declared + // there, with the same check and the same report the boot-time binding + // made before. + const everyoneSets = this.baselinePermissionSets.filter((name) => { + const boot = this.bootstrapPermissionSets.find((p) => p.name === name); + const offending = boot ? describeHighPrivilegeBits(boot) : null; + if (offending) { + ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { set: name, offending }); + } + return !offending; + }); + if (registerBuiltinPositions((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID, everyoneSets) === 0) { ctx.logger.warn( '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' - + 'them. Their sys_position rows still seed and no grant changes, but the security catalog read and ' - + 'GET /api/v1/meta/position will not list them.', + + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' + + '`everyone` baseline sets are granted through no position.', + ); + } + + // [ADR-0131 D3/D4] Bind the security catalog read to this engine: every + // authorization resolution over it (`resolveUserAuthzGrants`, core) reads + // positions, the sets they name and the set bodies through this reader. + // An engine with no reader bound resolves an empty catalog — no position + // distributes a set and no set grants — so a failure here is said at once. + try { + bindSecurityCatalogReader(ql, createSecurityCatalogReader({ registry: (ql as any).registry, metadata: metadata as any })); + } catch (e) { + ctx.logger.warn( + '[security] the security catalog read was NOT bound to the ObjectQL engine: ' + + `${(e as Error).message} Every permission set and every position-bound set resolves to nothing ` + + 'until the engine exposes its registry and the metadata service is registered.', ); } diff --git a/packages/spec/authorable-surface/identity.json b/packages/spec/authorable-surface/identity.json index 21cf207f7f5..05026288883 100644 --- a/packages/spec/authorable-surface/identity.json +++ b/packages/spec/authorable-surface/identity.json @@ -55,6 +55,7 @@ "identity/Position:description", "identity/Position:label", "identity/Position:name", + "identity/Position:permissionSets", "identity/Position:protection", "identity/SCIMAddress:country", "identity/SCIMAddress:formatted", From 1e3a4b102a72df6590859e7ecea34e1c6d452849 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:47:41 +0000 Subject: [PATCH 03/38] wip: conversion function, static catalog testkit, golden query move recorded Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- ...authz-context.batch-equivalence.testkit.ts | 11 +- .../__tests__/security-catalog.testkit.ts | 96 ++++++++++++ packages/core/src/security/index.ts | 13 ++ .../position-binding-conversion.test.ts | 116 ++++++++++++++ .../security/position-binding-conversion.ts | 144 ++++++++++++++++++ ...uthz-context.batch-equivalence.golden.json | 35 ++--- ...ve-authz-context.batch-equivalence.test.ts | 32 ++-- 7 files changed, 419 insertions(+), 28 deletions(-) create mode 100644 packages/core/src/security/__tests__/security-catalog.testkit.ts create mode 100644 packages/core/src/security/position-binding-conversion.test.ts create mode 100644 packages/core/src/security/position-binding-conversion.ts diff --git a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index 18454b94128..ebce12566b3 100644 --- a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -30,6 +30,7 @@ */ import type { ResolveUserAuthzGrantsOptions } from '../resolve-authz-context.js'; +import { bindStaticSecurityCatalog, catalogFromTables } from './security-catalog.testkit.js'; // ── Recording ObjectQL double ─────────────────────────────────────────────── @@ -48,6 +49,12 @@ export interface RecordedCall { object: string; where: unknown; limit: unknown; * while another is in flight JOINS the open leg. Sequential awaits therefore * count one leg each, and a `Promise.all` of any width counts one — which is * exactly the definition cloud#1539 measured latency against. + * + * [ADR-0131 D3/D4] The double also carries the security catalog the resolver + * reads positions, their sets and the set bodies from: the definitions the + * fixture's catalog rows convert to (`catalogFromTables` — the junction rows + * through the upgrade ceremony's own conversion). The catalog is in-process, + * so it adds no read and no leg. */ export function makeRecordingQl(tables: Record) { const calls: RecordedCall[] = []; @@ -62,7 +69,7 @@ export function makeRecordingQl(tables: Record) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindStaticSecurityCatalog({ calls, legOf, get legs() { return legs; }, @@ -103,7 +110,7 @@ export function makeRecordingQl(tables: Record) { inFlight -= 1; } }, - }; + }, catalogFromTables(tables)); } // ── Fixture matrix — one entry per shape the eight reads discriminate on ──── diff --git a/packages/core/src/security/__tests__/security-catalog.testkit.ts b/packages/core/src/security/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..34172f2d628 --- /dev/null +++ b/packages/core/src/security/__tests__/security-catalog.testkit.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * The authorization resolver reads positions, the sets they name and the set + * bodies from the catalog the security plugin binds to its engine + * (`bindSecurityCatalogReader`, ADR-0131 D3/D4). A unit suite's fake engine + * has no plugin, so it binds one here — over an in-memory registry holding the + * definitions the suite declares. + * + * {@link catalogFromTables} derives those definitions from a suite's row-shaped + * fixture: the `sys_permission_set` rows become set definitions, and the + * `sys_position_permission_set` rows become each position's `permissionSets` + * through `convertPositionBindingRows` — the very conversion the upgrade + * ceremony applies — so a fixture that resolved some grants from rows proves + * the converted definitions resolve the same ones. + */ + +import { + bindSecurityCatalogReader, + createSecurityCatalogReader, + type SecurityCatalogReader, +} from '../security-catalog.js'; +import { convertPositionBindingRows } from '../position-binding-conversion.js'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface StaticCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** A catalog over these definitions alone (first definition of a name wins). */ +export function createStaticSecurityCatalog(catalog: StaticCatalog): SecurityCatalogReader { + const byType: Record = { + position: [...(catalog.positions ?? [])], + permission: [...(catalog.permissions ?? [])], + capability: [], + }; + return createSecurityCatalogReader({ + registry: { + getItem: (type, name) => byType[type]?.find((d) => d.name === name), + listItems: (type) => byType[type] ?? [], + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + }); +} + +/** Bind {@link createStaticSecurityCatalog} to `engine`; returns the engine for chaining. */ +export function bindStaticSecurityCatalog(engine: T, catalog: StaticCatalog): T { + bindSecurityCatalogReader(engine, createStaticSecurityCatalog(catalog)); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to (module doc). + * A set name held by several rows takes the first row's body; a position name + * the conversion reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): StaticCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const positionRows = (tables.sys_position ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const systemPermissions = parsed(row.system_permissions ?? row.systemPermissions); + const tabPermissions = parsed(row.tab_permissions ?? row.tabPermissions); + permissions.push({ + name: row.name, + ...(systemPermissions !== undefined && systemPermissions !== null ? { systemPermissions } : {}), + ...(tabPermissions !== undefined && tabPermissions !== null ? { tabPermissions } : {}), + }); + } + const conversion = convertPositionBindingRows({ + positions: positionRows, + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index befc907abc1..a5bb8e0cf98 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -246,6 +246,19 @@ export { type SecurityCatalogReader, } from './security-catalog.js'; +// ADR-0131 D3/D4/D10 — the junction rows turned into the `permissionSets` each +// position definition carries. Applied by the upgrade ceremony (`os migrate`), +// never at boot. +export { + convertPositionBindingRows, + type CatalogRowForConversion, + type PositionBindingRow, + type PositionBindingConversionInput, + type PositionBindingFate, + type DanglingPositionBinding, + type PositionBindingConversion, +} from './position-binding-conversion.js'; + // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { diff --git a/packages/core/src/security/position-binding-conversion.test.ts b/packages/core/src/security/position-binding-conversion.test.ts new file mode 100644 index 00000000000..7109a76fae7 --- /dev/null +++ b/packages/core/src/security/position-binding-conversion.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4/D10] The pin of `convertPositionBindingRows` — the function + * the upgrade ceremony applies to turn junction rows into each position + * definition's `permissionSets`. + */ + +import { describe, expect, it } from 'vitest'; +import { convertPositionBindingRows } from './position-binding-conversion.js'; + +const sets = [ + { id: 'ps_read', name: 'read_all' }, + { id: 'ps_write', name: 'write_all' }, + { id: 'ps_base', name: 'member_default' }, +]; + +describe('convertPositionBindingRows', () => { + it('names each position\'s bound sets by NAME, in row order', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_auditor', name: 'auditor' }, { id: 'p_contrib', name: 'contributor' }], + permissionSets: sets, + bindings: [ + { id: 'b1', position_id: 'p_contrib', permission_set_id: 'ps_write' }, + { id: 'b2', position_id: 'p_auditor', permission_set_id: 'ps_read' }, + { id: 'b3', position_id: 'p_contrib', permission_set_id: 'ps_read' }, + ], + }); + expect(out).toEqual({ + positions: [ + { name: 'contributor', fate: 'converted', permissionSets: ['write_all', 'read_all'] }, + { name: 'auditor', fate: 'converted', permissionSets: ['read_all'] }, + ], + dangling: [], + }); + }); + + it('keeps what the definition already names, and adds only what the rows bind beyond it', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_contrib', name: 'contributor' }, { id: 'p_auditor', name: 'auditor' }], + permissionSets: sets, + bindings: [ + { position_id: 'p_contrib', permission_set_id: 'ps_write' }, + { position_id: 'p_contrib', permission_set_id: 'ps_read' }, + { position_id: 'p_auditor', permission_set_id: 'ps_read' }, + ], + declared: new Map([ + ['contributor', ['member_default', 'write_all']], + ['auditor', ['read_all']], + ]), + }); + expect(out.positions).toEqual([ + { name: 'contributor', fate: 'converted', permissionSets: ['member_default', 'write_all', 'read_all'] }, + { name: 'auditor', fate: 'declared', permissionSets: ['read_all'] }, + ]); + }); + + it('one name materialized per organization with the SAME bindings converts once', () => { + const out = convertPositionBindingRows({ + positions: [ + { id: 'e_a', name: 'everyone', organization_id: 'org_a' }, + { id: 'e_b', name: 'everyone', organization_id: 'org_b' }, + ], + permissionSets: [ + { id: 'base_a', name: 'member_default', organization_id: 'org_a' }, + { id: 'base_b', name: 'member_default', organization_id: 'org_b' }, + ], + bindings: [ + { position_id: 'e_a', permission_set_id: 'base_a' }, + { position_id: 'e_b', permission_set_id: 'base_b' }, + ], + }); + expect(out.positions).toEqual([{ name: 'everyone', fate: 'converted', permissionSets: ['member_default'] }]); + }); + + it('organizations that bind DIFFERENT sets to one name are conflicting — never merged (D10 fate 4)', () => { + const out = convertPositionBindingRows({ + positions: [ + { id: 'm_a', name: 'manager', organization_id: 'org_a' }, + { id: 'm_b', name: 'manager', organization_id: 'org_b' }, + { id: 'm_c', name: 'manager', organization_id: 'org_c' }, + ], + permissionSets: sets, + bindings: [ + { position_id: 'm_a', permission_set_id: 'ps_write' }, + { position_id: 'm_b', permission_set_id: 'ps_read' }, + ], + }); + expect(out.positions).toEqual([ + { + name: 'manager', + fate: 'conflicting', + // org_c holds a row of the name and binds nothing: merging would hand it org_a's and org_b's sets. + byOrganization: { org_a: ['write_all'], org_b: ['read_all'], org_c: [] }, + }, + ]); + }); + + it('a binding to a missing position or set row is dangling and converted nowhere', () => { + const out = convertPositionBindingRows({ + positions: [{ id: 'p_auditor', name: 'auditor' }], + permissionSets: sets, + bindings: [ + { id: 'b_gone_position', position_id: 'p_gone', permission_set_id: 'ps_read' }, + { id: 'b_gone_set', position_id: 'p_auditor', permission_set_id: 'ps_gone' }, + ], + }); + expect(out).toEqual({ + positions: [], + dangling: [ + { id: 'b_gone_position', reason: 'position-missing' }, + { id: 'b_gone_set', reason: 'permission-set-missing' }, + ], + }); + }); +}); diff --git a/packages/core/src/security/position-binding-conversion.ts b/packages/core/src/security/position-binding-conversion.ts new file mode 100644 index 00000000000..b200619a3bd --- /dev/null +++ b/packages/core/src/security/position-binding-conversion.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4/D10] The `sys_position_permission_set` junction rows, turned + * into the `permissionSets` each position definition carries. + * + * The authorization resolver reads a position's sets from its definition + * (`PositionSchema.permissionSets`) and no longer reads the junction. The rows + * a deployment already holds are converted by the upgrade ceremony + * (`os migrate`, C7b), which calls this function and applies its answer through + * the metadata door. ⛔ Never at boot: a conversion that ran on every start + * would be the dual source ADR-0131 D10 retires. + * + * The function is pure. It takes the three tables as read, and the definitions + * the catalog already holds, and answers per position name: + * + * - `declared` — the definition already names every set the rows bind: + * nothing to write. + * - `converted` — the rows bind sets the definition does not name: + * `permissionSets` is the definition's list followed by the rows' sets, in + * row order. A binding only ever adds, so the declared sets stay. + * - `conflicting` — organizations that hold a row of this name bind different + * sets to it (an organization's row with no binding binds the empty list). + * Merging them would grant one organization's members another's sets, so the + * name is reported and never guessed (D10 fate 4). + * + * A binding whose position or set row is missing, or names nothing, is listed + * in `dangling` and converted nowhere. + */ + +/** One `sys_position` or `sys_permission_set` row, as the conversion reads it. */ +export interface CatalogRowForConversion { + readonly id?: unknown; + readonly name?: unknown; + readonly organization_id?: unknown; +} + +/** One `sys_position_permission_set` row. */ +export interface PositionBindingRow { + readonly id?: unknown; + readonly position_id?: unknown; + readonly permission_set_id?: unknown; +} + +/** The rows and definitions a conversion reads. */ +export interface PositionBindingConversionInput { + readonly positions: readonly CatalogRowForConversion[]; + readonly permissionSets: readonly CatalogRowForConversion[]; + readonly bindings: readonly PositionBindingRow[]; + /** The `permissionSets` each position definition in the catalog names today, by position name. */ + readonly declared?: ReadonlyMap; +} + +/** The answer for one position name. */ +export type PositionBindingFate = + | { readonly name: string; readonly fate: 'declared'; readonly permissionSets: readonly string[] } + | { readonly name: string; readonly fate: 'converted'; readonly permissionSets: readonly string[] } + | { + readonly name: string; + readonly fate: 'conflicting'; + /** The sets each organization's row binds, keyed by organization id (`''` for an organization-less row). */ + readonly byOrganization: Readonly>; + }; + +/** A binding the conversion could not place. */ +export interface DanglingPositionBinding { + readonly id: string | undefined; + readonly reason: 'position-missing' | 'permission-set-missing'; +} + +export interface PositionBindingConversion { + /** One entry per position name a binding reaches, in first-binding order. */ + readonly positions: readonly PositionBindingFate[]; + readonly dangling: readonly DanglingPositionBinding[]; +} + +const text = (value: unknown): string | undefined => + (typeof value === 'string' && value !== '') || typeof value === 'number' ? String(value) : undefined; + +const organizationKey = (value: unknown): string => text(value) ?? ''; + +/** + * Convert the junction rows into position definitions' `permissionSets` + * (module doc). Deterministic: the same rows answer the same result. + */ +export function convertPositionBindingRows(input: PositionBindingConversionInput): PositionBindingConversion { + const positionById = new Map(); + for (const row of input.positions) { + const id = text(row.id); + const name = text(row.name); + if (id && name) positionById.set(id, { name, organization: organizationKey(row.organization_id) }); + } + const setNameById = new Map(); + for (const row of input.permissionSets) { + const id = text(row.id); + const name = text(row.name); + if (id && name) setNameById.set(id, name); + } + + // Position name → organization → the set names its row binds, in row order. + const bound = new Map>(); + const dangling: DanglingPositionBinding[] = []; + for (const binding of input.bindings) { + const position = positionById.get(text(binding.position_id) ?? ''); + if (!position) { + dangling.push({ id: text(binding.id), reason: 'position-missing' }); + continue; + } + const setName = setNameById.get(text(binding.permission_set_id) ?? ''); + if (!setName) { + dangling.push({ id: text(binding.id), reason: 'permission-set-missing' }); + continue; + } + let byOrganization = bound.get(position.name); + if (!byOrganization) bound.set(position.name, (byOrganization = new Map())); + const sets = byOrganization.get(position.organization) ?? []; + if (!sets.includes(setName)) sets.push(setName); + byOrganization.set(position.organization, sets); + } + + // Every organization holding a row of a bound name takes part, with what it binds. + for (const { name, organization } of positionById.values()) { + const byOrganization = bound.get(name); + if (byOrganization && !byOrganization.has(organization)) byOrganization.set(organization, []); + } + + const positions: PositionBindingFate[] = []; + for (const [name, byOrganization] of bound) { + const lists = [...byOrganization.values()]; + const signature = (sets: readonly string[]) => [...sets].sort().join('\u0000'); + if (new Set(lists.map(signature)).size > 1) { + positions.push({ name, fate: 'conflicting', byOrganization: Object.fromEntries(byOrganization) }); + continue; + } + const declared = input.declared?.get(name) ?? []; + const added = lists[0].filter((set) => !declared.includes(set)); + positions.push( + added.length === 0 + ? { name, fate: 'declared', permissionSets: [...declared] } + : { name, fate: 'converted', permissionSets: [...declared, ...added] }, + ); + } + return { positions, dangling }; +} diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json index 0884edd1989..8bb204b6985 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json @@ -288,15 +288,16 @@ "isSystem": true }, { - "object": "sys_position_permission_set", + "object": "sys_permission_set", "where": { - "position_id": { + "name": { "$in": [ - "p_contrib", - "p_auditor", - "p_everyone" + "write_all", + "read_all", + "base_access" ] - } + }, + "organization_id": null }, "limit": 500, "isSystem": true @@ -304,11 +305,11 @@ { "object": "sys_permission_set", "where": { - "id": { + "name": { "$in": [ - "ps_write", - "ps_read", - "ps_base" + "write_all", + "read_all", + "base_access" ] } }, @@ -506,14 +507,14 @@ "isSystem": true }, { - "object": "sys_position_permission_set", + "object": "sys_permission_set", "where": { - "position_id": { + "name": { "$in": [ - "p_orgadmin", - "p_everyone" + "organization_admin" ] - } + }, + "organization_id": null }, "limit": 500, "isSystem": true @@ -542,9 +543,9 @@ { "object": "sys_permission_set", "where": { - "id": { + "name": { "$in": [ - "ps_oa" + "organization_admin" ] } }, diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index 297e0b23446..67aa715892b 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -47,6 +47,21 @@ * sequential capture. The fixtures' grants carry the name the platform writers * store beside the id. The by-name read joins the `sys_position` read's leg, so * a principal whose sets are all held directly resolves in two legs, not three. + * + * ⚠️ A second recorded move ([ADR-0131] D3/D4, C3 stage 1): a position's sets + * are the `permissionSets` its catalog definition names, and every set body is + * its catalog definition. The recording double carries that catalog: the + * definitions the fixture's own rows convert to, the junction rows through the + * upgrade ceremony's `convertPositionBindingRows`. In the two fixtures whose + * positions bind sets (`position-derived-grants`, `tenant-admin-via-position`), + * the `sys_position_permission_set` read and the `sys_permission_set` read by + * the junction's ids are gone; in their place the position-bound sets' rows are + * read BY NAME, for their ADR-0049 `active` flag only — the organization-less + * rows and the active organization's own rows, the pair a user grant's sets + * get. Same query count. Every `grants` envelope is byte-identical to the + * capture: the switch moved where a grant is read from, never what it grants. + * The by-name pair needs only the position NAMES, so those two fixtures resolve + * in three legs, not four. */ import { readFileSync } from 'node:fs'; @@ -84,21 +99,20 @@ const GOLDEN: Record = JSON.parse( * control, written out per fixture rather than derived, so a regression that * re-serialises one read shows up as a number rather than as a slower suite. * - * Four is the floor, not three: wave 1 (every independent read) → wave 2 - * (`sys_position`, needs the position NAMES wave 1 produced) → wave 3 - * (`sys_position_permission_set`, needs the position IDS wave 2 produced) → - * wave 4 (`sys_permission_set` by the junction's ids). The directly granted - * sets are read by NAME in wave 2, beside `sys_position` ([ADR-0131] D4), so a - * principal with no `sys_position` row backing any of its position names skips - * waves 3 and 4 and lands in 2. + * Three is the floor, not two: wave 1 (every independent read) → wave 2 + * (`sys_position`, needs the position NAMES wave 1 produced) → wave 3 (the + * position-bound sets' `sys_permission_set` rows, by the names the held + * positions' catalog definitions name — [ADR-0131] D3/D4). The directly + * granted sets are read by NAME in wave 2, beside `sys_position` (D4), so a + * principal whose positions name no set skips wave 3 and lands in 2. */ const BATCHED_LEGS: Record = { 'empty-principal': 2, 'multi-org-membership': 2, 'lapsed-own-membership-among-active-peers': 2, - 'position-derived-grants': 4, + 'position-derived-grants': 3, 'permission-set-derived-grants': 2, - 'tenant-admin-via-position': 4, + 'tenant-admin-via-position': 3, 'ai-seat-and-email-from-sys-user': 2, 'ai-seat-denied': 2, 'seeded-permissions-and-email': 2, From d6a43e5f94babb5177ab7b9e82ffe0bc8e0e6331 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:52:03 +0000 Subject: [PATCH 04/38] wip: core suites read the catalog; admin-standing surface re-measured Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- ...authz-context.batch-equivalence.testkit.ts | 6 +- .../__tests__/security-catalog.testkit.ts | 30 ++- .../security/admin-standing-surface.test.ts | 5 +- .../src/security/admin-standing-surface.ts | 42 ++-- .../security/authz-store-unavailable.test.ts | 15 +- ...ve-authz-context.grant-set-by-name.test.ts | 205 +++++++++++------- ...uthz-context.platform-admin-config.test.ts | 5 +- .../security/resolve-authz-context.test.ts | 72 +++--- .../resolve-user-grants-cache.test.ts | 5 +- .../plugin-auth/src/last-admin-guard.ts | 20 +- 10 files changed, 229 insertions(+), 176 deletions(-) diff --git a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index ebce12566b3..950c4a81658 100644 --- a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -30,7 +30,7 @@ */ import type { ResolveUserAuthzGrantsOptions } from '../resolve-authz-context.js'; -import { bindStaticSecurityCatalog, catalogFromTables } from './security-catalog.testkit.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; // ── Recording ObjectQL double ─────────────────────────────────────────────── @@ -69,7 +69,7 @@ export function makeRecordingQl(tables: Record) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return bindStaticSecurityCatalog({ + return bindCatalogFromTables({ calls, legOf, get legs() { return legs; }, @@ -110,7 +110,7 @@ export function makeRecordingQl(tables: Record) { inFlight -= 1; } }, - }, catalogFromTables(tables)); + }, tables); } // ── Fixture matrix — one entry per shape the eight reads discriminate on ──── diff --git a/packages/core/src/security/__tests__/security-catalog.testkit.ts b/packages/core/src/security/__tests__/security-catalog.testkit.ts index 34172f2d628..c784275b434 100644 --- a/packages/core/src/security/__tests__/security-catalog.testkit.ts +++ b/packages/core/src/security/__tests__/security-catalog.testkit.ts @@ -32,17 +32,21 @@ export interface StaticCatalog { permissions?: readonly Definition[]; } -/** A catalog over these definitions alone (first definition of a name wins). */ -export function createStaticSecurityCatalog(catalog: StaticCatalog): SecurityCatalogReader { - const byType: Record = { - position: [...(catalog.positions ?? [])], - permission: [...(catalog.permissions ?? [])], - capability: [], +/** + * A catalog over these definitions alone (first definition of a name wins). + * Given a function, the definitions are read afresh on every lookup — the + * shape for a suite that writes its fixture between two resolutions. + */ +export function createStaticSecurityCatalog(catalog: StaticCatalog | (() => StaticCatalog)): SecurityCatalogReader { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; }; return createSecurityCatalogReader({ registry: { - getItem: (type, name) => byType[type]?.find((d) => d.name === name), - listItems: (type) => byType[type] ?? [], + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), isPackageDisabled: () => false, }, metadata: { get: () => undefined, list: () => [] }, @@ -50,7 +54,7 @@ export function createStaticSecurityCatalog(catalog: StaticCatalog): SecurityCat } /** Bind {@link createStaticSecurityCatalog} to `engine`; returns the engine for chaining. */ -export function bindStaticSecurityCatalog(engine: T, catalog: StaticCatalog): T { +export function bindStaticSecurityCatalog(engine: T, catalog: StaticCatalog | (() => StaticCatalog)): T { bindSecurityCatalogReader(engine, createStaticSecurityCatalog(catalog)); return engine; } @@ -94,3 +98,11 @@ export function catalogFromTables(tables: Record(engine: T, tables: Record): T { + return bindStaticSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 21808eb0731..7dde4a14896 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -45,6 +45,7 @@ import { describe, it, expect } from 'vitest'; import { ADMIN_STANDING_SURFACE, adminStandingTables } from './admin-standing-surface.js'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveAuthzContext } from './resolve-authz-context.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** table -> every column name the resolver touched on it. */ type Observation = Map>; @@ -79,7 +80,7 @@ function makeRecordingQl(tables: Record>>, const raw = (row: Record, key: string): unknown => key in row ? row[key] : row[camelOf(key)]; - return { + return bindCatalogFromTables({ async find(object: string, opts: { where?: Record; limit?: number } = {}) { if (!seen.has(object)) seen.set(object, new Set()); const where = opts?.where ?? {}; @@ -116,7 +117,7 @@ function makeRecordingQl(tables: Record>>, }), ); }, - }; + }, tables); } const headers = () => new Headers(); diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index bcef643b418..cf3cefbdeab 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -120,17 +120,16 @@ export const ADMIN_STANDING_SURFACE: Readonly + "Its organization decides which row a grant's name resolves to — the grant's own " + "organization's row, else the organization-less one — so an UNSCOPED grant reaches only " + 'the organization-less `admin_full_access`: moving that row into an organization un-makes ' - + 'every grant-derived platform admin exactly as deleting it does.', + + 'every grant-derived platform admin exactly as deleting it does. [ADR-0131 D3/D4] Since ' + + 'the catalog switch the row is read for its `active` flag alone: the set itself — its ' + + 'existence and its body — is the catalog definition (see the `security-catalog` non-table ' + + 'input), and a name with no row carries no flag, so deleting the row no longer un-makes ' + + 'anyone; switching it off still does.', columns: [ - 'id', 'name', 'active', 'organization_id', 'organizationId', - 'system_permissions', - 'systemPermissions', - 'tab_permissions', - 'tabPermissions', ], }, @@ -206,8 +205,8 @@ export const ADMIN_STANDING_SURFACE: Readonly role: 'reads-only', reason: 'ADR-0057 D4 platform-RBAC position assignments (§4). A position can carry permission sets ' - + '(see `sys_position_permission_set`) but never platform-admin standing — §6b requires the ' - + 'set to be reached through an unscoped USER grant (`unscopedUserPsIds`), so a ' + + '(its catalog definition\'s `permissionSets`, ADR-0131 D3/D4) but never platform-admin ' + + 'standing — §6b requires the set to be reached through an unscoped USER grant, so a ' + 'position-bound `admin_full_access` resolves the set name into `permissions` and leaves ' + '`hasPlatformAdminGrant` false.', }, @@ -218,20 +217,16 @@ export const ADMIN_STANDING_SURFACE: Readonly 'Read to drop DEACTIVATED positions (ADR-0049, §6a). Same reason as `sys_user_position`: ' + 'the position path cannot reach `hasPlatformAdminGrant`.', }, - - sys_position_permission_set: { - role: 'reads-only', - reason: - 'Position-bound permission sets (§6a). Contributes ids to `psIds` — and therefore names to ' - + '`permissions` — but not to `unscopedUserPsIds`, which is the set §6b tests for ' - + 'platform-admin standing.', - }, }; /** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */ export interface AdminStandingNonTableInput { - /** How the value reaches the resolver, e.g. `env` for a process environment variable. */ - readonly kind: 'env'; + /** + * How the value reaches the resolver: `env` for a process environment + * variable, `security-catalog` for a definition the resolver reads from the + * catalog bound to its engine (ADR-0131 D3/D4). + */ + readonly kind: 'env' | 'security-catalog'; /** The exact spelling an operator sets — quotable verbatim in a refusal message. */ readonly name: string; /** What it decides, and what a break-glass guard can and cannot do about it. */ @@ -266,6 +261,17 @@ export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInpu + 'break-glass guard can simulate a change to it: revocation is a configuration change plus ' + 'a process roll, by design.', }, + { + kind: 'security-catalog', + name: 'admin_full_access', + reason: + 'The permission-set definition an unscoped `admin_full_access` user grant names, read from ' + + 'the security catalog by name (ADR-0131 D3/D4) — the platform\'s own declaration, shipped ' + + 'on the security plugin\'s manifest. A name the catalog does not hold confers nothing, so ' + + 'every grant-derived platform administrator rests on it. It is code-declared and refused ' + + 'an overlay at the metadata door, so no runtime write reaches it either: removing it is ' + + 'a code change plus a process roll.', + }, ]; /** The tables a write to which can change who is an administrator. */ diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 49f2e08b645..a1b35734797 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -32,6 +32,7 @@ import { AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_STATUS, } from './authz-store-unavailable.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const USER = 'u_admin'; const SESSION = { getSession: async () => ({ user: { id: USER } }) }; @@ -40,16 +41,18 @@ const SESSION = { getSession: async () => ({ user: { id: USER } }) }; const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); /** The INNOCENT TWIN: reachable, and genuinely holding no rows for this user. */ const qlEmpty = () => ({ find: async () => [] }); -/** A store that actually grants something, so "resolves" is read against a real grant. */ -const qlHealthy = () => ({ +/** + * A store that actually grants something, so "resolves" is read against a real + * grant: the grant row, and the set it names in the security catalog bound to + * the engine (ADR-0131 D3/D4). + */ +const qlHealthy = () => bindStaticSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin' }]; - if (object === 'sys_permission_set') { - return [{ id: 'ps', name: 'pkg_admin', system_permissions: ['studio.access'] }]; - } + if (object === 'sys_permission_set') return [{ id: 'ps', name: 'pkg_admin' }]; return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['studio.access'] }] }); const settle = async (p: Promise) => p.then((v) => ({ ok: true as const, v }), (e) => ({ ok: false as const, e })); diff --git a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts index 9561ebd5cf9..e7385551aed 100644 --- a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -1,23 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D4] `resolveUserAuthzGrants` §6 finds a user grant's permission - * set BY NAME — `sys_user_permission_set.permission_set` — on the grant's own - * organization's row, else the organization-less row. Its - * `permission_set_id` is not read. + * [ADR-0131 D3/D4] `resolveUserAuthzGrants` §6 resolves a user grant's + * permission set BY NAME — `sys_user_permission_set.permission_set` — in the + * security catalog: the set exists, and grants what it grants, because the + * catalog holds a definition of that name. Its `permission_set_id` is not read. + * The catalog row is read for one thing, its ADR-0049 `active` flag: the + * grant's own organization's row, else the organization-less row. * * What is pinned here, on the recording double the batch-equivalence suite - * drives (the real-engine half, over the SQL driver and the real write hooks, - * is `plugin-security`'s `resolve-authz-grant-set-by-name.test.ts`): + * drives (with an explicit catalog bound over it): * - * - a grant whose id names another organization's set confers nothing — the - * set row the id points at is never read; - * - a grant whose NAME is carried only by another organization's row confers - * nothing either; + * - the body is the catalog definition, whichever organization's row carries + * the name — and a name only a row carries (no definition) confers nothing; * - a grant that names nothing confers nothing, platform standing included; - * - an organization's own row wins over the organization-less one, and a - * deactivated own row is the answer — it does not fall through; - * - position-bound sets are still reached through the junction's id. + * - platform standing stands on an organization-less grant only; + * - the `active` flag: an organization's own row wins over the + * organization-less one, and a deactivated own row does not fall through; + * - position-bound sets are the ones the position's definition names, and the + * junction rows are not read at all. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -25,6 +26,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; import { resetPlatformAdminEmailMemo } from './platform-admin.js'; import { resolveUserAuthzGrants } from './resolve-authz-context.js'; import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ORG_A = 'org_a'; const ORG_B = 'org_b'; @@ -46,20 +48,34 @@ afterAll(() => { resetPlatformAdminEmailMemo(); }); -/** The catalog: one name per organization, plus the organization-less platform rows. */ +/** The catalog rows: one name per organization, plus the organization-less platform rows. */ const catalog = () => [ - { id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null, system_permissions: ['manage_users'] }, - { id: 'ps_admin_b', name: ADMIN_FULL_ACCESS, organization_id: ORG_B, system_permissions: ['manage_users'] }, - { id: 'ps_tools_a', name: 'tools', organization_id: ORG_A, system_permissions: ['cap_a'] }, - { id: 'ps_tools_b', name: 'tools', organization_id: ORG_B, system_permissions: ['cap_b'] }, - { id: 'ps_tools', name: 'tools', organization_id: null, system_permissions: ['cap_global'] }, - { id: 'ps_b_only', name: 'b_only', organization_id: ORG_B, system_permissions: ['cap_b_only'] }, - { id: 'ps_shared', name: 'shared', organization_id: null, system_permissions: ['cap_shared'] }, - { id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false, system_permissions: ['cap_off'] }, - { id: 'ps_on', name: 'switched', organization_id: null, active: true, system_permissions: ['cap_on'] }, - { id: 'ps_bound', name: 'bound_by_position', organization_id: null, system_permissions: ['cap_bound'] }, + { id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ps_admin_b', name: ADMIN_FULL_ACCESS, organization_id: ORG_B }, + { id: 'ps_tools_a', name: 'tools', organization_id: ORG_A }, + { id: 'ps_tools_b', name: 'tools', organization_id: ORG_B }, + { id: 'ps_tools', name: 'tools', organization_id: null }, + { id: 'ps_row_only', name: 'row_only', organization_id: ORG_B }, + { id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false }, + { id: 'ps_on', name: 'switched', organization_id: null, active: true }, + { id: 'ps_bound', name: 'bound_by_position', organization_id: null }, ]; +/** The definitions the catalog holds — the only place a set's body lives. */ +const definitions = { + permissions: [ + { name: ADMIN_FULL_ACCESS, systemPermissions: ['manage_users'] }, + { name: 'tools', systemPermissions: ['cap_tools'] }, + { name: 'switched', systemPermissions: ['cap_switched'] }, + { name: 'bound_by_position', systemPermissions: ['cap_bound'] }, + { name: 'no_row', systemPermissions: ['cap_no_row'] }, + ], + positions: [] as Array>, +}; + +/** The same catalog, with the `everyone` anchor distributing one set. */ +const withEveryone = { ...definitions, positions: [{ name: 'everyone', permissionSets: ['bound_by_position'] }] }; + function tables(grants: Array>, extra: Record = {}) { return { sys_user: [{ id: 'u1', email: 'u1@example.com' }], @@ -73,81 +89,101 @@ function tables(grants: Array>, extra: Record>, tenantId?: string, extra?: Record) { - const ql = makeRecordingQl(tables(grants, extra)); +async function resolve( + grants: Array>, + tenantId?: string, + extra?: Record, + catalogDefinitions: typeof definitions = definitions, +) { + const ql = bindStaticSecurityCatalog(makeRecordingQl(tables(grants, extra)), catalogDefinitions); const out = await resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {}); return { out, ql }; } -describe('[ADR-0131 D4] a grant whose id names another organization’s set confers nothing', () => { - it('an unnamed organization-less grant pointing at another organization’s set: nothing, and that row is never read', async () => { - for (const tenantId of [undefined, ORG_A, ORG_B]) { - const { out, ql } = await resolve([{ permission_set_id: 'ps_b_only', organization_id: null }], tenantId); - expect(out.permissions, String(tenantId)).not.toContain('b_only'); - expect(out.systemPermissions, String(tenantId)).not.toContain('cap_b_only'); - expect(JSON.stringify(ql.calls), String(tenantId)).not.toContain('ps_b_only'); +describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set is', () => { + it('the body is the definition, whichever organization\'s row carries the name', async () => { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [ORG_B, ORG_B], [null, undefined]] as const) { + const { out } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: grantOrg }], tenantId); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual(['tools']); + expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual(['cap_tools']); } }); - it('a grant named after a set only another organization carries: nothing, in that organization or out of it', async () => { - const orgLess = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: null }], ORG_B); - expect(orgLess.out.permissions).not.toContain('b_only'); - const ownOrg = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: ORG_A }], ORG_A); - expect(ownOrg.out.permissions).not.toContain('b_only'); - expect(ownOrg.out.systemPermissions).not.toContain('cap_b_only'); + it('a name only a ROW carries — no definition — confers nothing, in its organization or out of it', async () => { + for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [null, ORG_B], [null, undefined]] as const) { + const { out } = await resolve([{ permission_set_id: 'ps_row_only', permission_set: 'row_only', organization_id: grantOrg }], tenantId); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); + } }); - it('CONTROL — the same name on a row the grant may name confers it', async () => { - const own = await resolve([{ permission_set_id: 'ps_b_only', permission_set: 'b_only', organization_id: ORG_B }], ORG_B); - expect(own.out.permissions).toContain('b_only'); - expect(own.out.systemPermissions).toContain('cap_b_only'); + it('CONTROL — a definition with no row at all confers its body: no row carries no flag to read', async () => { + const { out } = await resolve([{ permission_set: 'no_row', organization_id: null }], ORG_A); + expect(out.permissions).toEqual(['no_row']); + expect(out.systemPermissions).toEqual(['cap_no_row']); }); - it('an organization-less grant named after admin_full_access stands on the organization-less row only', async () => { - const only = (rows: any[]) => ({ sys_permission_set: rows }); - const onCopy = await resolve( - [{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - ORG_B, - only(catalog().filter((r) => r.id !== 'ps_admin')), - ); - expect(onCopy.out.posture).not.toBe('PLATFORM_ADMIN'); - expect(onCopy.out.permissions).not.toContain(ADMIN_FULL_ACCESS); + it('the id does not decide: a grant named `tools` whose id points at another set resolves `tools`', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: 'tools', organization_id: ORG_A }], ORG_A); + expect(out.permissions).toEqual(['tools']); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + }); - const onPlatformRow = await resolve( - [{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - ORG_B, - ); - expect(onPlatformRow.out.posture).toBe('PLATFORM_ADMIN'); + it('the junction and the id bridge are never read; the catalog rows are read by NAME only', async () => { + const { ql } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: ORG_A }], ORG_A); + expect(ql.calls.map((c) => c.object)).not.toContain('sys_position_permission_set'); + for (const call of ql.calls.filter((c) => c.object === 'sys_permission_set')) { + expect(Object.keys(call.where as object)).not.toContain('id'); + expect(Object.keys(call.where as object)).toContain('name'); + } + }); +}); + +describe('[ADR-0068 D2] platform standing stands on an organization-less grant only', () => { + it('an organization-less grant named admin_full_access holds platform standing', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], ORG_B); + expect(out.posture).toBe('PLATFORM_ADMIN'); + expect(out.positions[0]).toBe('platform_admin'); }); - it('an ORGANIZATION’s admin_full_access grant is that organization’s set, never platform standing', async () => { + it('an ORGANIZATION’s admin_full_access grant carries the set, never platform standing', async () => { const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B); expect(out.permissions).toContain(ADMIN_FULL_ACCESS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); }); -}); -describe('[ADR-0131 D4] the row a name resolves to', () => { - it('an organization’s grant reads its own row first, and the organization-less row only where it has none', async () => { - const own = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: ORG_A }], ORG_A); - expect(own.out.systemPermissions).toEqual(['cap_a']); - const fallback = await resolve([{ permission_set_id: 'ps_shared', permission_set: 'shared', organization_id: ORG_A }], ORG_A); - expect(fallback.out.systemPermissions).toEqual(['cap_shared']); - // An organization-less grant of the same name reads the organization-less row, inside an organization too. - const global = await resolve([{ permission_set_id: 'ps_tools', permission_set: 'tools', organization_id: null }], ORG_A); - expect(global.out.systemPermissions).toEqual(['cap_global']); + it('with no admin_full_access definition in the catalog, the grant confers nothing at all', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + undefined, + undefined, + { ...definitions, permissions: definitions.permissions.filter((d) => d.name !== ADMIN_FULL_ACCESS) }, + ); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + expect(out.permissions).toEqual([]); }); + it('a DEACTIVATED organization-less row takes the standing away', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + undefined, + { sys_permission_set: [{ id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null, active: false }] }, + ); + expect(out.posture).not.toBe('PLATFORM_ADMIN'); + expect(out.permissions).toEqual([]); + }); +}); + +describe('[ADR-0049] the row that carries the `active` flag', () => { it('a deactivated own row is the answer: it confers nothing and does not fall through to the organization-less row', async () => { const { out } = await resolve([{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], ORG_A); expect(out.permissions).not.toContain('switched'); expect(out.systemPermissions).toEqual([]); }); - it('the id does not decide: a grant named `tools` whose id points at another set resolves `tools`', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_shared', permission_set: 'tools', organization_id: ORG_A }], ORG_A); - expect(out.permissions).toEqual(['tools']); - expect(out.systemPermissions).toEqual(['cap_a']); + it('CONTROL — an organization-less grant of the same name reads the organization-less row, which is active', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_on', permission_set: 'switched', organization_id: null }], ORG_A); + expect(out.permissions).toEqual(['switched']); + expect(out.systemPermissions).toEqual(['cap_switched']); }); }); @@ -167,19 +203,24 @@ describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { expect(out.positions).not.toContain('platform_admin'); expect(out.permissions).not.toContain(ADMIN_FULL_ACCESS); }); +}); - it('CONTROL — the same grant carrying its name holds platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], undefined); - expect(out.posture).toBe('PLATFORM_ADMIN'); - expect(out.positions[0]).toBe('platform_admin'); - }); - - it('position-bound sets are still read through the junction’s id (ADR-0131 C3)', async () => { - const { out } = await resolve([], ORG_A, { +describe('[ADR-0131 D3/D4] position-bound sets are the ones the position definition names', () => { + it('the `everyone` definition\'s set resolves; a junction row binding another set confers nothing', async () => { + const { out, ql } = await resolve([], ORG_A, { sys_position: [{ id: 'p_everyone', name: 'everyone', organization_id: ORG_A }], - sys_position_permission_set: [{ position_id: 'p_everyone', permission_set_id: 'ps_bound' }], - }); + sys_position_permission_set: [{ position_id: 'p_everyone', permission_set_id: 'ps_tools_a' }], + }, withEveryone); expect(out.permissions).toEqual(['bound_by_position']); expect(out.systemPermissions).toEqual(['cap_bound']); + expect(ql.calls.map((c) => c.object)).not.toContain('sys_position_permission_set'); + }); + + it('a DEACTIVATED position row drops the sets its definition names', async () => { + const { out } = await resolve([], ORG_A, { + sys_position: [{ id: 'p_everyone', name: 'everyone', organization_id: ORG_A, active: false }], + }, withEveryone); + expect(out.permissions).toEqual([]); + expect(out.positions).not.toContain('everyone'); }); }); diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index f4d5e0abc57..16b409d85d8 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -44,6 +44,7 @@ import { resolveAuthzContext, resolveUserAuthzGrants, } from './resolve-authz-context.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** @@ -105,14 +106,14 @@ function makeQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ calls, async find(object: string, opts: any) { calls.push({ object, where: structuredClone(opts?.where ?? null) }); const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** A `sys_user`-only fixture: no grant rows anywhere, so standing can only be config-derived. */ diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 4e5a7ed3b91..a056c2cd4ce 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -5,6 +5,7 @@ import { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, import { POSTURE_RANK } from './posture-ladder.js'; import { hashApiKey } from './api-key.js'; import type { AuthzPosture } from '@objectstack/spec/security'; +import { bindCatalogFromTables, bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; /** * [#14273 A1] The refusal REASON is observable on exactly ONE surface: the @@ -54,7 +55,7 @@ function bounded(rows: T[], opts: any): T[] { } function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = tables[object] ?? []; const where = opts?.where ?? {}; @@ -70,7 +71,7 @@ function makeQl(tables: Record) { opts, ); }, - }; + }, tables); } const session = (userId: string, opts: { email?: string; org?: string } = {}) => async () => ({ user: { id: userId, email: opts.email }, session: { activeOrganizationId: opts.org ?? null } }); @@ -161,7 +162,7 @@ describe('resolveAuthzContext — single source of truth', () => { // assert the de-duplication of redundant authz/localization reads (#2409). function makeCountingQl(tables: Record) { const counts: Record = {}; - return { + return bindCatalogFromTables({ counts, async find(object: string, opts: any) { counts[object] = (counts[object] ?? 0) + 1; @@ -179,7 +180,7 @@ function makeCountingQl(tables: Record) { opts, ); }, - }; + }, tables); } describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', () => { @@ -1940,14 +1941,15 @@ describe('[#20515] with no active organization, only global grants apply', () => }); /** - * §6a — position-bound sets. Under a walled posture the catalog holds one - * copy of each built-in position PER ORGANIZATION (`everyone`, `org_member`, - * …), and an organization binds its own sets to its own copies. With no - * tenant the `sys_position` read is installation-wide, so every - * organization's copy of a name the caller holds used to feed its bindings in. - * This double ignores the context's tenant exactly as that read does. + * [ADR-0131 D3/D4] The catalog has no organization level: a position's sets + * are the `permissionSets` its definition names, the same in every + * organization and with none. The organization-scoped junction rows below — + * alpha binding its own sets to its own copies of `org_member` and + * `everyone` — are not read, so a member removed from alpha cannot keep + * what alpha bound, in beta or with no tenant, and neither can alpha's own + * members: what an organization bound is not a grant any more (#15196 Q3 A). */ - describe('§6a: a position row scoped to an organization binds nothing with no tenant', () => { + describe('§6a: a position binds what its definition names — no organization binds its own', () => { const catalog = () => ({ sys_position: [ { id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA }, @@ -1966,35 +1968,35 @@ describe('[#20515] with no active organization, only global grants apply', () => { id: 'ps_global_everyone', name: 'global_everyone_default' }, ], }); - /** Removed from alpha, still an `org_member` of beta — the role name alpha bound its set to. */ - const exMember = () => makeQl({ - sys_user: [{ id: 'u_ex' }], - sys_member: [{ user_id: 'u_ex', organization_id: BETA, role: 'member' }], + const definitions = { + positions: [{ name: 'everyone', permissionSets: ['global_everyone_default'] }], + permissions: [ + { name: 'alpha_member_tools', systemPermissions: ['manage_metadata'] }, + { name: 'alpha_everyone_extra' }, + { name: 'global_everyone_default' }, + ], + }; + const principal = (userId: string, organizationId: string) => bindStaticSecurityCatalog(makeQl({ + sys_user: [{ id: userId }], + sys_member: [{ user_id: userId, organization_id: organizationId, role: 'member' }], sys_user_position: [], sys_user_permission_set: [], ...catalog(), + }), definitions); + + it('a member removed from alpha: the definition\'s set and nothing alpha bound — with no tenant and in beta', async () => { + for (const tenantId of [undefined, BETA]) { + const grants = await resolveUserAuthzGrants(principal('u_ex', BETA), 'u_ex', tenantId ? { tenantId } : {}); + expect(grants.positions, String(tenantId)).toContain('org_member'); + expect([...grants.permissions].sort(), String(tenantId)).toEqual(['global_everyone_default']); + expect(grants.systemPermissions, String(tenantId)).not.toContain('manage_metadata'); + } }); - it('no tenant: neither alpha\'s org_member binding nor alpha\'s everyone binding applies; the global everyone binding does', async () => { - const grants = await resolveUserAuthzGrants(exMember(), 'u_ex', {}); - expect(grants.positions).toContain('org_member'); - expect([...grants.permissions].sort()).toEqual(['global_everyone_default']); - expect(grants.systemPermissions).not.toContain('manage_metadata'); - }); - - it('in beta: alpha\'s bindings still do not apply; in alpha (a current member there): they do', async () => { - const inBeta = await resolveUserAuthzGrants(exMember(), 'u_ex', { tenantId: BETA }); - expect([...inBeta.permissions].sort()).toEqual(['global_everyone_default']); - const alphaMember = makeQl({ - sys_user: [{ id: 'u_in' }], - sys_member: [{ user_id: 'u_in', organization_id: ALPHA, role: 'member' }], - sys_user_position: [], - sys_user_permission_set: [], - ...catalog(), - }); - const inAlpha = await resolveUserAuthzGrants(alphaMember, 'u_in', { tenantId: ALPHA }); - expect([...inAlpha.permissions].sort()).toEqual(['alpha_everyone_extra', 'alpha_member_tools', 'global_everyone_default']); - expect(inAlpha.systemPermissions).toContain('manage_metadata'); + it('a current alpha member gets the same: alpha\'s organization-scoped junction rows bind nothing', async () => { + const inAlpha = await resolveUserAuthzGrants(principal('u_in', ALPHA), 'u_in', { tenantId: ALPHA }); + expect([...inAlpha.permissions].sort()).toEqual(['global_everyone_default']); + expect(inAlpha.systemPermissions).not.toContain('manage_metadata'); }); }); diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index 42167bc9f0c..1f3138c933d 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -36,6 +36,7 @@ import { makeRecordingQl, FIXTURES, } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── An engine double that carries the #11968 seams ────────────────────────── @@ -87,7 +88,7 @@ function makeSeamQl(tables: Record) { }; await run(0); } - return { + return bindCatalogFromTables({ calls, tables, writeEpoch: epoch, @@ -121,7 +122,7 @@ function makeSeamQl(tables: Record) { tables[object] = (tables[object] ?? []).filter((r) => !matches(r, where)); }); }, - }; + }, tables); } type SeamQl = ReturnType; diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 9c6e2313e8a..d0ab905c93d 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -854,23 +854,9 @@ export const STANDING_KEY_EXCLUSIONS: Readonly Date: Sat, 10 Oct 2026 08:57:37 +0000 Subject: [PATCH 05/38] wip: runtime and plugin-security fixtures bind the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/explain-enforce-parity.test.ts | 34 ++++--- ...tion-gate-positions-name-authority.test.ts | 5 +- ...tomation-run-lifecycle-deny-remedy.test.ts | 5 +- .../meta-read-org-scope-parity.test.ts | 5 +- ...ges-orgless-grants-capability-gate.test.ts | 14 ++- ...s-read-delete-response-conformance.test.ts | 5 +- .../src/domains/packages-single-door.test.ts | 5 +- ...ges-uninstall-refuse-before-mutate.test.ts | 5 +- .../packages-vetted-org-source.test.ts | 5 +- .../resolve-execution-context.test.ts | 9 +- .../src/security/security-catalog.testkit.ts | 92 +++++++++++++++++++ 12 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/runtime/src/security/security-catalog.testkit.ts diff --git a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 5d3e0678d96..f9820cd3d23 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -283,7 +283,8 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' ? [MEMBER_DEFAULT, set].find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, set], }, }; @@ -345,7 +346,9 @@ const USER_REMOVED = 'usr_alpha_removed'; const READER_SET = 'qa_parity_reader'; /** * Authored only as a `sys_permission_set` row OF `org_alpha` (no metadata - * declaration), granted in `org_alpha`: it opens the global note object. + * declaration), granted in `org_alpha`. It would open the global note object, + * but a set the security catalog does not hold confers nothing (ADR-0131 + * D3/D4, #15196 Q3 A): `NOTES` is refused to every principal, on both faces. */ const ALPHA_ONLY_SET = 'qa_parity_alpha_notes'; @@ -364,7 +367,8 @@ type PostureSource = * organizations; * - `PROBE` — platform-global (`tenancy: { enabled: false }`), so only the * grants decide; - * - `NOTES` — platform-global, opened only by {@link ALPHA_ONLY_SET}. + * - `NOTES` — platform-global, granted only by {@link ALPHA_ONLY_SET}, which no + * definition backs — so refused to everyone. * * Enforcement's face is `resolveAuthzContext` with a session that claims * `org_alpha`, handed the posture admission hands it, assembled by @@ -438,7 +442,10 @@ async function bootPrincipal(source: PostureSource) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? [MEMBER_DEFAULT, userAdminSet, readerSet].find((s) => s.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, userAdminSet, readerSet], }, ...('tenancy' in source ? { tenancy: { posture: source.tenancy } } : { 'org-scoping': {} }), @@ -572,7 +579,8 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, list: async () => sets, }, get sharing() { return sharing; }, @@ -879,27 +887,27 @@ const TABLE: Row[] = [ // #20580 (the removed member) and #20604 position 3 (the current member), under each walled posture. ...principalRows('#20580 · #20604 P3', { tenancy: 'isolated' }, '`isolated`', { removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }), ...principalRows('#20580 · #20604 P3', { tenancy: 'group' }, '`group`', { removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }), ...principalRows('#20580 control · #20604 P3', { tenancy: 'single' }, '`single`', { - removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }, { removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, }), // The posture-source asymmetry: the member's rows hold; the removed member's are enforcement's finding. ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { - removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, - member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }, { removed: Object.fromEntries( - ['sets', 'allowed:LEDGER', 'readFilter:LEDGER', 'record:l_alpha', 'allowed:PROBE', 'readFilter:PROBE', - 'allowed:NOTES', 'readFilter:NOTES'].map((k) => [k, CLAIM_KEPT_UNDER_A_WALL]), + ['sets', 'allowed:LEDGER', 'readFilter:LEDGER', 'record:l_alpha', 'allowed:PROBE', 'readFilter:PROBE'] + .map((k) => [k, CLAIM_KEPT_UNDER_A_WALL]), ), }), ]; diff --git a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts index 8ef1f3b9d35..6ca5b702e41 100644 --- a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts +++ b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts @@ -50,6 +50,7 @@ import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/c import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const FLOW = 'vendor_lead_router'; const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; @@ -97,12 +98,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'name-only' | 'genuine' | 'plain'; diff --git a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts index d10115e3ce3..28545421823 100644 --- a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts +++ b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts @@ -45,6 +45,7 @@ import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/ty import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { resolveExecutionContext } from '../security/resolve-execution-context.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; /** * The environment inputs the derivation reads: the two posture variables in @@ -77,12 +78,12 @@ function makeQl(tables: Tables) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** A verified `sys_user` row and nothing else — no membership, position or grant. */ diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 34468d618b9..1d6762d39d0 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -50,6 +50,7 @@ import { organizationIdForMetaRead } from '@objectstack/metadata-core'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { RestServer } from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; // ── The metadata store: env-wide rows and each organization's overlays ──────── @@ -166,12 +167,12 @@ function makeQl() { if (cond === null) return (row[field] ?? null) === null; return row[field] === cond; }); - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string | null } diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index dcefec280a7..627f58a6bcb 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -45,6 +45,7 @@ import { join } from 'node:path'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; @@ -148,13 +149,13 @@ function rig() { }, }; const services: Record = { - objectql: { + objectql: bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const found = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; }, - }, + }, TABLES), auth: { api: { getSession: async ({ headers }: any) => { @@ -260,11 +261,14 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(switchedOff(r)).toBe(true); }); - it('CONTROL · the position-bound set: a current org_alpha member with org_alpha active passes: 200', async () => { + // [ADR-0131 D3/D4] A position's sets are the ones its catalog definition + // names; org_alpha's junction row on ITS copy of `org_member` binds nothing + // — for its current members as for the removed one (#15196 Q3 A). + it('the organization-scoped junction row binds nothing: a current org_alpha member holding org_member is refused: 403', async () => { const r = rig(); const answer = await r.call('DELETE', 'posmember', `/packages/${PKG}`); - expect(answer.status).toBe(200); - expect(r.deleteRequests).toEqual([{ packageId: PKG }]); + expect(answer.status).toBe(403); + expect(r.deleteRequests).toEqual([]); }); it('a GLOBAL grant still passes the gate for the removed member — the uninstall proceeds, naming no organization', async () => { diff --git a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts index 7976e40b5ee..82debf602e4 100644 --- a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts @@ -88,6 +88,7 @@ import { } from '@objectstack/spec/api-assembled'; import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; @@ -168,13 +169,13 @@ function dispatcher(manifests: any[], protocol?: unknown): HttpDispatcher { (registry as any).logLevel = 'silent'; for (const m of manifests) registry.installPackage(m as any); - const ql = { + const ql = bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const rows = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, TABLES); const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { activeOrganizationId: ORG } }) } }; const services: Record = { objectql: ql, auth, ...(protocol ? { protocol } : {}) }; return new HttpDispatcher({ diff --git a/packages/runtime/src/domains/packages-single-door.test.ts b/packages/runtime/src/domains/packages-single-door.test.ts index 7f78c710856..7407a2a06cd 100644 --- a/packages/runtime/src/domains/packages-single-door.test.ts +++ b/packages/runtime/src/domains/packages-single-door.test.ts @@ -60,6 +60,7 @@ import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher, type HttpDispatcherResult } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; const ENV_ID = 'env_alpha'; @@ -130,13 +131,13 @@ function registryWith(): SchemaRegistry { } function kernelWith(registry: SchemaRegistry): any { - const ql = { + const ql = bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const rows = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, TABLES); const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }; const services: Record = { objectql: ql, auth }; return { diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index 78faea3e18c..a5dce100370 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -46,6 +46,7 @@ import { join } from 'node:path'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; @@ -156,13 +157,13 @@ function rig(opts: { persistedHalf?: boolean } = {}): Rig { }; const services: Record = { - objectql: { + objectql: bindCatalogFromTables({ registry, find: async (object: string, q: any = {}) => { const found = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; }, - }, + }, TABLES), auth: authService(), tenancy: { posture: 'isolated' }, ...(opts.persistedHalf === false ? {} : { protocol }), diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts index 5b515e608c9..7f0a2051851 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -62,6 +62,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import type { IHttpServer } from '@objectstack/spec/contracts'; import { HttpDispatcher } from '../http-dispatcher.js'; import { createDispatcherPlugin } from '../dispatcher-plugin.js'; +import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PREFIX = '/api/v1'; const PKG = 'com.acme.crm'; @@ -206,7 +207,7 @@ function makeQl() { return row[field] === cond; }); const pkg = { manifest: { id: PKG, name: 'CRM', version: '1.0.0', scope: 'project' } }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; @@ -219,7 +220,7 @@ function makeQl() { // subject here is the organization the protocol call carries. uninstallPackage: () => false, }, - }; + }, tables); } interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string } diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 83d506e84df..41005a0f00b 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -6,6 +6,7 @@ import { ObjectKernel, isAuthzStoreUnavailableError, isServiceNotRegisteredError import { resolveExecutionContext } from './resolve-execution-context.js'; import { hashApiKey } from './api-key.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; /** * Minimal ObjectQL stub. Only `sys_api_key` is populated; every other object @@ -329,7 +330,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 sys_position_permission_set: [], sys_user_position: [], }; - return { + return bindCatalogFromTables({ async find(object, opts) { const rows = tables[object] ?? []; const where = opts?.where ?? {}; @@ -344,7 +345,7 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 return true; }), opts); }, - }; + }, tables); } const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); @@ -505,7 +506,7 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_users","manage_platform_settings"]', object_permissions: '{}' }], sys_position: [], sys_position_permission_set: [], sys_user_position: [], }; - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { return bounded((tables[object] ?? []).filter((row) => Object.entries(opts?.where ?? {}).every(([k, v]) => @@ -515,7 +516,7 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc ), ), opts); }, - }; + }, tables); }; const agentWithQl = (scopes: string[], ql: any) => ({ acceptOAuthAccessToken: true, diff --git a/packages/runtime/src/security/security-catalog.testkit.ts b/packages/runtime/src/security/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/runtime/src/security/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} From 4da892e0c16ba1c1ef79156f174f6dc9cf63f014 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:12:14 +0000 Subject: [PATCH 06/38] wip: plugin-security fixtures declare the catalog; everyone baseline declared at kernel:ready Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/builtin-positions.boot.test.ts | 54 +++++++++++++++---- .../plugin-security/src/builtin-positions.ts | 37 +++++++------ .../src/explain-engine.test.ts | 13 ++--- .../explain-positions-name-authority.test.ts | 5 +- .../explain-removed-member-principal.test.ts | 5 +- ...grant-permission-set-name-backfill.test.ts | 10 +++- ...nt-permission-set-name.equivalence.test.ts | 5 +- .../src/grant-readers-by-name.golden.test.ts | 5 +- .../src/orgless-position-name-fold.test.ts | 7 ++- .../src/per-organization-catalog.test.ts | 23 ++++++-- .../src/position-catalog-refusal.test.ts | 14 ++++- .../src/position-write-through.test.ts | 15 ++++-- ...lve-authz-grant-set-by-name.golden.test.ts | 5 +- .../resolve-authz-grant-set-by-name.test.ts | 29 ++++++++-- .../src/security-plugin.test.ts | 34 +++++++++++- .../plugin-security/src/security-plugin.ts | 43 +++++++++------ 16 files changed, 232 insertions(+), 72 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 5deaa488906..144125ec4ee 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -252,7 +252,10 @@ async function boot(scenario: Scenario): Promise { }; const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'position' || type === 'permission' + ? ((await metadata.list(type)) as Array<{ name?: string }>).find((d) => d.name === name) ?? null + : engine.getSchema(name) ?? null, list: async (type: string) => { if (type === 'position') return DECLARED_POSITIONS.map((p) => ({ ...p })); if (type === 'permission') return [...defaultPermissionSets, QA_ADMIN, APP_DEFAULT]; @@ -472,7 +475,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; - expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(GRANT_GOLDEN[scenario.posture]); + expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(grantGoldenFor(scenario)); }); it('still refuses a tenant a dangling position name, and deleting or relabelling a built-in row', async () => { @@ -700,12 +703,20 @@ const DOOR_AUTHORED_ROWS: Record<'single' | 'walled', { census: string[]; ledger }; /** - * Recorded before the declarations (module doc). `field_default` on every - * human principal is the `everyone` binding; `single` also binds the - * platform's `member_default` (its organization-less catalog holds that set), - * while the walled organization's catalog holds only its own copy of the app's - * set. The organization administrator's set is the walled `organization_admin` + * Recorded before the declarations (module doc). `field_default` and + * `member_default` on every human principal are the `everyone` anchor's sets. + * The organization administrator's set is the walled `organization_admin` * against the wall-less `organization_admin_no_bypass` (ADR-0105 D4). + * + * ⚠️ One recorded move ([ADR-0131] D3/D4, C3 stage 1): `everyone`'s sets are + * the baseline its definition names — the same in every posture. Before, they + * were junction rows the boot wrote per organization, and the walled + * organization's catalog held only its own copy of the app's set, so walled + * `everyone` never bound the organization-less `member_default`. It now + * names it under a wall too. `member_default` carries no system or tab + * permission, and enforcement applied it to every human as the additive + * baseline already (ADR-0090 D5), so the walled envelope gains its name and + * nothing it grants. */ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { single: { @@ -771,7 +782,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'member@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'viewer_readonly'], + permissions: ['field_default', 'member_default', 'viewer_readonly'], positions: ['everyone', 'org_member'], posture: 'MEMBER', systemPermissions: [], @@ -780,7 +791,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: ['org_eq'], email: 'orgadmin@eq.example', org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'organization_admin'], + permissions: ['field_default', 'member_default', 'organization_admin'], positions: ['everyone', 'org_owner'], posture: 'TENANT_ADMIN', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], @@ -789,7 +800,7 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { accessible_org_ids: [], email: 'admin@eq.example', org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access', 'field_default'], + permissions: ['admin_full_access', 'field_default', 'member_default'], positions: ['everyone', 'platform_admin'], posture: 'PLATFORM_ADMIN', systemPermissions: [ @@ -805,3 +816,26 @@ const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { }, }, }; + +/** The `everyone` anchor's sets in {@link GRANT_GOLDEN} — the deployment's baseline. */ +const BASELINE_SETS = ['field_default', 'member_default']; + +/** + * {@link GRANT_GOLDEN} for a scenario. Under {@link SHADOWING_DEFINITIONS} a + * stored package-less `everyone` definition answers the catalog read ahead of + * the built-in (the registry's bare-slot precedence, ADR-0005) and names no + * set, so the envelope carries no baseline name — enforcement still applies the + * baseline additively (ADR-0090 D5). A v18 boot refuses such a stored + * definition under a packaged name (`os migrate security-catalog-overlays`). + */ +function grantGoldenFor(scenario: Scenario): unknown { + const golden = GRANT_GOLDEN[scenario.posture] as Record; + if (!scenario.shadowedBuiltins) return golden; + return Object.fromEntries( + Object.entries(golden).map(([who, grants]) => [ + who, + who === 'agent' ? grants : { ...grants, permissions: grants.permissions.filter((p) => !BASELINE_SETS.includes(p)) }, + ]), + ); +} + diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 02f61b0325c..e7eb9539209 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -61,9 +61,9 @@ * [ADR-0090 D5, ADR-0131 D3/D4] The `everyone` anchor distributes the * deployment's baseline sets — the app's `isDefault` set composed with the * platform's `member_default` — in its definition's `permissionSets`, which - * the authorization resolver reads. The plugin passes that list - * ({@link registerBuiltinPositions}'s `permissionSets`); no other built-in - * names a set. + * the authorization resolver reads. The plugin declares it at `kernel:ready` + * ({@link declareEveryoneBaseline}), once the stack's declared capabilities + * the high-privilege check reads are in; no other built-in names a set. */ import { @@ -79,8 +79,6 @@ export interface BuiltinPositionDeclaration { readonly name: string; readonly label: string; readonly description: string; - /** The sets this built-in distributes (`PositionSchema.permissionSets`) — `everyone`'s baseline only. */ - readonly permissionSets?: readonly string[]; } /** @@ -134,23 +132,30 @@ export interface BuiltinPositionRegistry { * registration gets a fresh copy: the registry stamps provenance onto the * object it is handed, and the list above is shared. * - * `everyoneSets` is the baseline the `everyone` anchor distributes (module - * doc); an empty list declares no `permissionSets` at all. - * * @returns how many were registered — 0 when `registry` cannot register * anything, which the caller reports (absence is loud). */ -export function registerBuiltinPositions( - registry: unknown, - packageId: string, - everyoneSets: readonly string[] = [], -): number { +export function registerBuiltinPositions(registry: unknown, packageId: string): number { const target = registry as Partial | null | undefined; if (!target || typeof target.registerItem !== 'function') return 0; for (const declaration of securityBuiltinPositions) { - const item: Record = { ...declaration }; - if (declaration.name === EVERYONE_POSITION && everyoneSets.length > 0) item.permissionSets = [...everyoneSets]; - target.registerItem('position', item, 'name', packageId); + target.registerItem('position', { ...declaration }, 'name', packageId); } return securityBuiltinPositions.length; } + +/** + * [ADR-0090 D5, ADR-0131 D3/D4] Declare the `everyone` anchor again, owned by + * `packageId`, now naming `sets` — the deployment's baseline — in its + * `permissionSets` (module doc). The same package re-registering its own item + * replaces it. + * + * @returns whether it was declared — `false` when `registry` cannot register. + */ +export function declareEveryoneBaseline(registry: unknown, packageId: string, sets: readonly string[]): boolean { + const target = registry as Partial | null | undefined; + if (!target || typeof target.registerItem !== 'function') return false; + const everyone = securityBuiltinPositions.find((p) => p.name === EVERYONE_POSITION)!; + target.registerItem('position', { ...everyone, permissionSets: [...sets] }, 'name', packageId); + return true; +} diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 38ad24d5002..942c47bf9db 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -9,6 +9,7 @@ import { explainAccess, buildContextForUser, resolveDelegatorContext, type Expla import { RLS_DENY_FILTER } from './rls-compiler'; import { unresolvedPostureRemedy } from './unresolved-posture'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // [commit a68c61267] `ExplainDecision.layers` is `ExplainLayer[]` — the z.INPUT shape // (ADR-0122), in which every `.default([])` member is OPTIONAL before a parse: @@ -869,7 +870,7 @@ type Rows = Record; /** Minimal `where`-honouring ObjectQL stand-in: scalar equality and `$in`. */ function makeGrantQl(tables: Rows) { - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const where = opts?.where ?? {}; return (tables[object] ?? []).filter((row) => @@ -885,7 +886,7 @@ function makeGrantQl(tables: Rows) { }), ); }, - }; + }, tables); } const NOW = Date.parse('2026-07-10T12:00:00Z'); @@ -1457,11 +1458,11 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis return () => { listeners.delete(l); }; }, }; - return { + return bindCatalogFromTables({ ...makeGrantQl(tables), writeEpoch: epoch, registerMiddleware(fn: Middleware): void { middlewares.push(fn); }, - }; + }, tables); } it('with the cache ON and provably stale, the explainer still observes the revocation immediately', async () => { @@ -1827,14 +1828,14 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an describe('resolveDelegatorContext: the delegator is resolved in the live principal\'s organization', () => { const delegatorQl = () => { const t = tables(); - return { + return bindCatalogFromTables({ ...makeGrantQl(t), async findOne(object: string, opts: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, opts); const id = (opts as any)?.where?.id; return (t[object] ?? []).find((r) => r.id === id) ?? null; }, - }; + }, t); }; it('live principal in org_alpha → the delegator\'s org_alpha grant applies, its org_beta grant does not', async () => { diff --git a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts index 5cb58cf5a32..505f5628a4f 100644 --- a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts +++ b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts @@ -54,6 +54,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { hasPlatformAdminStanding } from '@objectstack/core'; import { PermissionEvaluator } from './permission-evaluator'; import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ADMIN = PermissionSetSchema.parse({ name: 'admin_full_access', @@ -112,13 +113,13 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ getSchema: () => PRIVATE_SCHEMA, async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } function authzTables(shape: 'name-only' | 'genuine') { diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index 7f7afd28383..f4af169fbb8 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -176,7 +176,10 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? [MEMBER_DEFAULT, userAdminSet, probeSet].find((s) => s.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, userAdminSet, probeSet], }, tenancy: { posture }, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 63bf8516de5..f1d32e55865 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -139,7 +139,10 @@ async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise< manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(posture === 'isolated' @@ -623,7 +626,10 @@ describe('[ADR-0131 D4] grant name backfill — on a real kernel boot', () => { expect((await grant(engine, 'g_late'))?.permission_set).toBeNull(); const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }; const kernel = new LiteKernel({ logger: { level: 'silent' } }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 05beb82fcbe..a880b75389a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -121,7 +121,10 @@ async function grantsByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(posture === 'isolated' diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 65a0e71bd73..dec8f180673 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -137,7 +137,10 @@ async function readersByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(walled diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 5d69f4a6814..2b06764472b 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -160,6 +160,11 @@ async function boot(): Promise { id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_1_global_platform_ops', permission_set: 'platform_ops', organization_id: null, }, SYS); + // [ADR-0131 D3/D4] The two GLOBAL sets are declared in the catalog, which is + // what makes a grant of them confer anything; the sets ORG_OTHER authored are + // rows only, with no definition (#15196 Q3 A). + engine.registry.registerItem('permission', { name: 'ops_lead', systemPermissions: ['probe.global_ops_lead'] }, 'name', 'com.test.ops'); + engine.registry.registerItem('permission', { name: 'platform_ops', systemPermissions: ['probe.global_platform_ops'] }, 'name', 'com.test.ops'); return engine; } @@ -208,7 +213,7 @@ async function rig(): Promise { hook: () => { /* collected, never fired — see the file header */ }, getService: (n: string) => { if (n === 'objectql') return ql; - if (n === 'metadata') return { list: async () => [] }; + if (n === 'metadata') return { get: async () => undefined, list: async () => [] }; if (n === 'manifest') return { register: () => {} }; return services[n]; }, diff --git a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts index 26efad2b171..7bdff2281e9 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -51,6 +51,7 @@ import { SysPositionPermissionSet } from './objects/sys-position-permission-set. import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ORG_JIA = 'org_jia'; const ORG_YI = 'org_yi'; @@ -299,7 +300,7 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { expect(setAgain).toBe(setSnapshot); }); - it('6. the cross-organization grant bleed is closed: the REAL resolver no longer collects another organization’s binding', async () => { + it('6. the cross-organization grant bleed is closed: the REAL resolver collects no organization’s junction binding', async () => { const engine = await boot(); const { logger } = recordingLogger(); for (const org of ORGS) await seedCatalog(engine, logger, org); @@ -327,13 +328,25 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { const yiGrants = await resolveUserAuthzGrants(engine as any, 'u_yi', { tenantId: ORG_YI }); expect(yiGrants.permissions).not.toContain('jia_wide_grant'); - // Positive control on the same resolver, from the same fixture: org_jia's - // own member DOES get it. Without this the assertion above would pass on a - // resolver that had simply stopped resolving anything. + // [ADR-0131 D3/D4] …and org_jia's own member does not get it either: the + // resolver reads a position's sets from its catalog definition, so a + // junction row binds nothing anywhere (#15196 Q3 A). await (engine as any).insert('sys_user_position', { id: 'up_jia', user_id: 'u_jia', position: 'everyone' }, { context: { isSystem: true, tenantId: ORG_JIA } }); const jiaGrants = await resolveUserAuthzGrants(engine as any, 'u_jia', { tenantId: ORG_JIA }); - expect(jiaGrants.permissions).toContain('jia_wide_grant'); + expect(jiaGrants.permissions).not.toContain('jia_wide_grant'); + + // Positive control on the same resolver, from the same fixture: a set the + // `everyone` DEFINITION names reaches both organizations' members. Without + // it the assertions above would pass on a resolver that had simply stopped + // resolving anything. + bindTestSecurityCatalog(engine, { + positions: [{ name: 'everyone', permissionSets: ['jia_wide_grant'] }], + permissions: [{ name: 'jia_wide_grant' }], + }); + for (const [user, org] of [['u_yi', ORG_YI], ['u_jia', ORG_JIA]] as const) { + expect((await resolveUserAuthzGrants(engine as any, user, { tenantId: org })).permissions).toContain('jia_wide_grant'); + } }); }); diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index c58d0d88d33..b694ff7372e 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -139,7 +139,10 @@ async function boot(opts: { walled?: boolean } = {}) { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN, QA_ORG_ADMIN_LIKE] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [MEMBER_DEFAULT, QA_ADMIN, QA_ORG_ADMIN_LIKE], }, ...(opts.walled @@ -191,6 +194,15 @@ async function boot(opts: { walled?: boolean } = {}) { { id: 'inq_2', subject: 'two' }, { id: 'inq_3', subject: 'three' }, ], { context: seed } as any); + // [ADR-0131 D3/D4] What the resolver reads: the set's definition, and each + // position's definition naming it — the junction rows above bind nothing. + const pkg = 'com.objectstack.qa.position-catalog-refusal'; + engine.registry.registerItem('permission', { + name: 'qa_auditor_set', label: 'QA Auditor', objects: { qa_inquiry: { allowRead: true, viewAllRecords: true } }, + } as any, 'name' as any, pkg); + for (const [name, label] of [['qa_auditor', 'Auditor'], ['qa_retired', 'Retired']]) { + engine.registry.registerItem('position', { name, label, permissionSets: ['qa_auditor_set'] } as any, 'name' as any, pkg); + } return { engine, warn }; } diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index daeac0a8ad4..51eded43bab 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -150,7 +150,10 @@ async function boot(opts: { posture?: 'single' | 'isolated'; door?: boolean; led ? null : new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async (type: string) => (type === 'position' ? [] : [...defaultPermissionSets, QA_ADMIN]), }; const services: Record = { @@ -246,7 +249,12 @@ describe('[ADR-0131 D3] position write-through under single — a Setup position expect(await b.catalogResolves('regional_lead')).toBe(true); }); - it('a user assigned to a Setup-created position is granted exactly what the row-only position granted', async () => { + // [ADR-0131 D3/D4] The resolver reads a position's sets from its definition's + // `permissionSets`, never from the junction: the binding Setup makes as a + // `sys_position_permission_set` row grants nothing, with the write-through or + // without it. Setup moves onto the definition with C9 and the write-through's + // retirement; until then this is the measured gap the release note names. + it('a user assigned to a Setup-created position is granted the same with or without the write-through — and a junction binding grants nothing', async () => { const grantsThrough = async (door: boolean) => { const b = await boot({ door }); await b.engine.insert('sys_user', { @@ -276,7 +284,8 @@ describe('[ADR-0131 D3] position write-through under single — a Setup position expect(before.defined).toBe(0); expect(after.defined).toBe(1); expect(after.grants).toEqual(before.grants); - expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']), permissions: ['pw_reports'] }); + expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']) }); + expect((after.grants as any).permissions).not.toContain('pw_reports'); }); it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index c642a3e396b..94f6a4a7edc 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -118,7 +118,10 @@ async function resolverByPrincipal(posture: Posture): Promise engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets, QA_ADMIN], }, ...(walled diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts index 3b051593cfd..0b35c694426 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -96,7 +96,10 @@ async function boot(opts: { walled?: boolean } = {}): Promise { manifest: { register: vi.fn() }, objectql: engine, metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }, ...(opts.walled @@ -175,12 +178,24 @@ describe('[ADR-0131 D4] a grant whose id names another organization’s set conf } }); - it('CONTROL — an organization’s own grant of that set confers it, in that organization only', async () => { + // [ADR-0131 D3/D4] The set is the catalog's definition of its name: a set + // only an organization's ROW carries confers nothing, to that organization's + // own grant too (#15196 Q3 A) — and declared, it confers. + it('an organization’s own grant of a set only its row carries confers nothing, even in that organization', async () => { const engine = await world(); await engine.insert('sys_user_permission_set', { id: 'g_own', user_id: 'usr_x', permission_set_id: 'ps_b_tools', organization_id: ORG_B }, { context: SYS } as any); expect((await grant(engine, 'g_own'))?.permission_set).toBe('b_tools'); + expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_B })).systemPermissions).not.toContain('cap_b'); + }); + + it('CONTROL — the same grant of a set the catalog declares confers it, in that organization only', async () => { + const engine = await world(); + engine.registry.registerItem('permission', { name: 'b_tools', systemPermissions: ['cap_b'] } as any, 'name' as any, CATALOG_PACKAGE); + await engine.insert('sys_user_permission_set', + { id: 'g_own', user_id: 'usr_x', permission_set_id: 'ps_b_tools', organization_id: ORG_B }, + { context: SYS } as any); expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_B })).systemPermissions).toContain('cap_b'); expect((await resolveUserAuthzGrants(engine, 'usr_x', { tenantId: ORG_A })).systemPermissions).not.toContain('cap_b'); }); @@ -220,7 +235,10 @@ describe('[ADR-0131 D4] the upgrade boot — no request reaches the resolver bef engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); } engine.registry.registerItem( - 'permission', { name: 'upgrade_reviewer', label: 'Reviewer', objects: {} } as any, 'name' as any, CATALOG_PACKAGE, + 'permission', + { name: 'upgrade_reviewer', label: 'Reviewer', objects: {}, systemPermissions: ['cap_review'] } as any, + 'name' as any, + CATALOG_PACKAGE, ); // Stored before this boot: the set row, and a grant with no name. await engine.insert('sys_permission_set', @@ -234,7 +252,10 @@ describe('[ADR-0131 D4] the upgrade boot — no request reaches the resolver bef const seen: Record = {}; const metadata = { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + get: async (type: string, name: string) => + type === 'permission' + ? ([...defaultPermissionSets] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, list: async () => [...defaultPermissionSets], }; const kernel = new LiteKernel({ logger: { level: 'silent' } }); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index dd150c33196..2368f214e91 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3803,7 +3803,11 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { const services: Record = { manifest: { register: vi.fn() }, objectql: ql, - metadata: { get: async () => baseSchema, list: async () => [memberDefault, subAdmin, hrAdmin] }, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? [memberDefault, subAdmin, hrAdmin].find((s: any) => s.name === name) ?? null : baseSchema, + list: async () => [memberDefault, subAdmin, hrAdmin], + }, }; const ctx: any = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -4411,7 +4415,17 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 sys_position_permission_set: [], }; const inserts: Array<{ object: string; data: any }> = []; + // [ADR-0131 D3/D4] The registry the built-in positions are declared to — + // where `everyone` names the baseline the boot binding above judges. + const items = new Map(); + const registry = { + registerItem: (type: string, item: any) => { items.set(`${type}:${item.name}`, item); }, + getItem: (type: string, name: string) => items.get(`${type}:${name}`), + listItems: (type: string) => [...items.entries()].filter(([k]) => k.startsWith(`${type}:`)).map(([, v]) => v), + isPackageDisabled: () => false, + }; const ql: any = { + registry, registerMiddleware: () => {}, find: async (object: string, opts?: any) => { const where = opts?.where ?? {}; @@ -4483,7 +4497,11 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 String(c[0]).includes('refusing to bind fallback set to everyone'), ); const bindings = inserts.filter((i) => i.object === 'sys_position_permission_set'); - return { plugin, ql, ctx, bindings, refusals }; + const declaredRefusals = ctx.logger.warn.mock.calls.filter((c: any[]) => + String(c[0]).includes('the everyone anchor does not name a baseline set'), + ); + const everyoneSets = registry.getItem('position', 'everyone')?.permissionSets; + return { plugin, ql, ctx, bindings, refusals, declaredRefusals, everyoneSets }; } it('binds the isDefault set when the stack declares its systemPermissions token', async () => { @@ -4495,6 +4513,18 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 expect(bindings[0].data).toMatchObject({ position_id: 'pos_everyone', permission_set_id: 'ps_app' }); }); + // [ADR-0131 D3/D4] The `everyone` definition names the same baseline the + // binding above writes, judged by the same check against the same context. + it('the everyone definition names the isDefault set when the stack declares its token, and not when it does not', async () => { + const declared = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + expect(declared.declaredRefusals).toHaveLength(0); + expect(declared.everyoneSets).toEqual(['app_member_default', 'member_default']); + const undeclared = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + expect(undeclared.declaredRefusals).toHaveLength(1); + expect(undeclared.declaredRefusals[0][1]).toMatchObject({ set: 'app_member_default', offending: 'system permissions' }); + expect(undeclared.everyoneSets).toEqual(['member_default']); + }); + it('still refuses an UNDECLARED token — the control for the case above', async () => { // Declarations exist and name a DIFFERENT capability: it is MEMBERSHIP that // excuses a token, not the presence of a declaration list. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 437c8a73f6b..e6eeaefad96 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -178,7 +178,7 @@ import { securityPluginManifestHeader, SECURITY_PLUGIN_ID, } from './manifest.js'; -import { registerBuiltinPositions } from './builtin-positions.js'; +import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js'; /** * [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the @@ -1785,21 +1785,8 @@ export class SecurityPlugin implements Plugin { // engine handle `start()` already holds, before `kernel:ready` runs the // seeders and before any request reaches the metadata door. Rows keep // seeding from the same list (`bootstrapBuiltinRoles`). - // - // [ADR-0090 D5, ADR-0131 D3/D4] `everyone` declares the deployment's - // baseline in its `permissionSets` — the binding the authorization resolver - // reads. A platform set carrying high-privilege bits is never declared - // there, with the same check and the same report the boot-time binding - // made before. - const everyoneSets = this.baselinePermissionSets.filter((name) => { - const boot = this.bootstrapPermissionSets.find((p) => p.name === name); - const offending = boot ? describeHighPrivilegeBits(boot) : null; - if (offending) { - ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { set: name, offending }); - } - return !offending; - }); - if (registerBuiltinPositions((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID, everyoneSets) === 0) { + const builtinRegistry = (ql as { registry?: unknown }).registry; + if (registerBuiltinPositions(builtinRegistry, SECURITY_PLUGIN_ID) === 0) { ctx.logger.warn( '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' @@ -1807,6 +1794,30 @@ export class SecurityPlugin implements Plugin { + '`everyone` baseline sets are granted through no position.', ); } + // [ADR-0090 D5, ADR-0131 D3/D4] `everyone` declares the deployment's + // baseline in its `permissionSets` — the binding the authorization resolver + // reads, in place of the junction rows the boot used to write. At + // `kernel:ready`, where that binding ran, because the high-privilege check + // reads the stack's declared capabilities (#18535) and they are all in by + // then; the check and its report are the binding's own. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', async () => { + try { + const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); + const everyoneSets = this.baselinePermissionSets.filter((name) => { + const boot = this.bootstrapPermissionSets.find((p) => p.name === name); + const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null; + if (offending) { + ctx.logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set: name, offending }); + } + return !offending; + }); + if (everyoneSets.length > 0) declareEveryoneBaseline(builtinRegistry, SECURITY_PLUGIN_ID, everyoneSets); + } catch (e) { + ctx.logger.warn('[security] everyone-anchor baseline declaration failed (non-fatal)', { error: (e as Error).message }); + } + }); + } // [ADR-0131 D3/D4] Bind the security catalog read to this engine: every // authorization resolution over it (`resolveUserAuthzGrants`, core) reads From ead16a02c3cba78c7943d9be6848c1aa1bc7b906 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:13:55 +0000 Subject: [PATCH 07/38] wip: rest fixtures declare the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ ...cctx-authz-input-seam-reachability.test.ts | 5 +- ...ernal-datasource-routes-auth-guard.test.ts | 4 + .../src/meta-type-read-capability.test.ts | 5 +- .../src/meta-type-write-capability.test.ts | 5 +- ...ge-door-execctx-fault-reachability.test.ts | 9 +- .../remote-tables-twin.equivalence.test.ts | 5 +- ...ugin-objectql-provider-three-state.test.ts | 5 +- .../src/rest-exec-ctx-principal-kind.test.ts | 5 +- packages/rest/src/rest-server-timing.test.ts | 5 +- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ 14 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/rest/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/services/service-automation/src/__tests__/security-catalog.testkit.ts diff --git a/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/rest/src/__tests__/security-catalog.testkit.ts b/packages/rest/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/rest/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index e14d7009334..0ba8b5c493f 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -102,6 +102,7 @@ import { import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; import { RestServer } from './rest-server.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // [#17865] This file observes the REST fault log, so it declares the level it // asserts against instead of inheriting the suite's quiet one. 'info' is the @@ -434,12 +435,12 @@ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: s { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } /** A real kernel, as `kernelManager.getOrCreate` would hand back. */ diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index 591def599a2..d7010df8083 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -82,6 +82,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco // `direct-mount-introspection.test.ts`): under `moduleResolution: nodenext` an // extension-less one does not resolve and every symbol it names becomes `any`. import { createRestApiPlugin } from './rest-api-plugin.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const BASE = '/api/v1'; const DS = 'pg_main'; @@ -263,6 +264,9 @@ async function bootFederation( }, }; + // [ADR-0131 D3/D4] The set's definition, which the resolver reads its body from. + bindTestSecurityCatalog(engine, { permissions: [{ name: 'federation_caller', systemPermissions: [...grants] }] }); + const services: Record = { 'http.server': server, protocol: makeProtocol(), diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index b806d3c188f..7877b481885 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -30,6 +30,7 @@ import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; // Explicit `.js` extension: NodeNext resolution (see the sibling meta tests). import { RestServer } from './rest-server.js'; import { META_TYPE_READ_CAPABILITIES, metaTypeReadRefusal } from './meta-item-read-gate.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -357,7 +358,7 @@ function bounded(rows: T[], opts: any): T[] { return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; } -const grantsEngine = { +const grantsEngine = bindCatalogFromTables({ async find(object: string, opts: any) { const rows = GRANT_TABLES[object] ?? []; const where = opts?.where ?? {}; @@ -374,7 +375,7 @@ const grantsEngine = { opts, ); }, -}; +}, GRANT_TABLES); const authService = { api: { diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index 8af0220ae69..19460cb3915 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -37,6 +37,7 @@ import { metaTypeWriteRefusal, } from './meta-item-read-gate.js'; import { FEDERATION_WRITE_CAPABILITY } from './external-datasource-routes.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -314,7 +315,7 @@ const GRANT_TABLES: Record = { })), }; -const grantsEngine = { +const grantsEngine = bindCatalogFromTables({ async find(object: string, opts: any) { const rows = GRANT_TABLES[object] ?? []; const where = opts?.where ?? {}; @@ -329,7 +330,7 @@ const grantsEngine = { ); return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, -}; +}, GRANT_TABLES); const authService = { api: { diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index 456e87a5a4a..881ba6839f6 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -150,6 +150,7 @@ import { import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; import { RestServer } from './rest-server.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const PKGS = '/api/v1/packages'; /** @@ -257,15 +258,13 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ * the SHIPPED aggregation (`sys_user_permission_set` → `sys_permission_set` * → `system_permissions`) rather than by handing the door a ready-made context. */ -const qlHealthy = () => ({ +const qlHealthy = () => bindTestSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; - if (object === 'sys_permission_set') { - return [{ id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }]; - } + if (object === 'sys_permission_set') return [{ id: 'ps_pkg', name: 'pkg_admin' }]; return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'] }] }); /** The same store, unreachable — every read throws, as a driver outage does. */ const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index 54d5e4e02d1..e3bc01b2ff0 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -59,6 +59,7 @@ import { } from '@objectstack/service-datasource'; import type { IntrospectedColumn, IntrospectedSchema } from '@objectstack/spec/contracts'; import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const DS = 'demo_ext'; @@ -186,7 +187,7 @@ const HOLDS: Record = { // `u_twin_plain` holds nothing, deliberately — it is not listed. }; -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { @@ -216,7 +217,7 @@ const makeQl = () => ({ } return []; }, -}); +}, { permissions: Object.values(SETS) }); /** * One server, one service, both registrars — the point of the fixture. diff --git a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts index 0b39a44876e..cafc1489668 100644 --- a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts +++ b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts @@ -69,6 +69,7 @@ import { import type { PluginContext } from '@objectstack/core'; import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); @@ -257,7 +258,7 @@ const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_ * `system_permissions`) — the healthy fixture * `package-door-execctx-fault-reachability.test.ts` established. */ -const engineGranting = () => ({ +const engineGranting = () => bindTestSecurityCatalog({ find: async (object: string) => { if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; if (object === 'sys_permission_set') { @@ -265,7 +266,7 @@ const engineGranting = () => ({ } return []; }, -}); +}, { permissions: [{ name: 'pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'] }] }); /** A `RestServer` wired at its constructor seams — nothing private replaced. */ function serverWith(objectQLProvider: EngineProvider | undefined): InstanceType { diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index 6a1bcfec68c..79b1461df97 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect, vi } from 'vitest'; import { hashApiKey } from '@objectstack/core'; import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; import { RestServer } from './rest-server'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const TASK = { name: 'task', @@ -46,7 +47,7 @@ const RAW_API_KEY = 'osk_6071_rest_face'; * `admin_full_access` grant for `admin1` (→ PLATFORM_ADMIN). Everything else * resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_api_key') { @@ -70,7 +71,7 @@ const makeQl = () => ({ } return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** A fake auth service whose session is keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts index f06390d79fb..e5d020bfe84 100644 --- a/packages/rest/src/rest-server-timing.test.ts +++ b/packages/rest/src/rest-server-timing.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const makeServer = () => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -27,7 +28,7 @@ const makeServer = () => ({ * PLATFORM_ADMIN) and a plain `member_default` grant for `member1` (→ MEMBER). * Every other object read resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; @@ -49,7 +50,7 @@ const makeQl = () => ({ // sys_setting (localization) — nothing to contribute. return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** A fake auth service whose session is keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} From cb7e21d38a6da9115153eafac588949f5c8e4da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:16:38 +0000 Subject: [PATCH 08/38] wip: plugin-auth fixtures declare the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-manager.test.ts | 10 ++++++---- .../src/impersonation-bearer-rotation.test.ts | 6 ++++-- .../plugin-auth/src/organization-add-member.test.ts | 6 ++++-- .../src/sso-register-platform-admin-gate.test.ts | 6 +++++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index a8e0b9e5cd2..1892a42d630 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -35,6 +35,8 @@ vi.mock('better-auth/plugins/haveibeenpwned', () => ({ })); import { betterAuth } from 'better-auth'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; +import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; describe('AuthManager', () => { let consoleSpy: ReturnType; @@ -3498,7 +3500,7 @@ describe('AuthManager', () => { // dataEngine stub: `adminLinks` controls whether the user resolves as a // platform admin (a sys_user_permission_set row pointing at the // admin_full_access permission set with no organization scope). - const makeDataEngine = (opts: { platformAdmin: boolean }) => ({ + const makeDataEngine = (opts: { platformAdmin: boolean }) => bindTestSecurityCatalog({ // Two parameters because the seam has two: `IDataEngine.find(objectName, // query?, options?)`, and every production read reaching this double goes // through `resolve-authz-context.ts` `tryFind`, which always calls @@ -3516,7 +3518,7 @@ describe('AuthManager', () => { return []; }), findOne: vi.fn(), - }); + }, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); const getSessionCallback = async (dataEngine: any) => { let capturedConfig: any; @@ -4795,7 +4797,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi * criterion must now ignore entirely. `find` honours the `where` the judge * passes, so any org-scoping is the product's, not the fixture's. */ - const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => ({ + const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => bindTestSecurityCatalog({ find: vi.fn(async (object: string, query?: any) => { if (opts.throws) throw new Error('db down'); if (object === 'sys_user_permission_set') { @@ -4813,7 +4815,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi return []; }), findOne: vi.fn(), - }); + }, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); /** The criterion, invoked exactly as the `/sso/register` hook invokes it. */ const judge = async (engine: any, userId = 'u-1'): Promise => { diff --git a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts index 970ba4a70e5..5ac366786ab 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts @@ -32,6 +32,7 @@ import { ADMIN_SESSION_RECOVERY_REQUEST_HEADER, ADMIN_SESSION_RECOVERY_RESPONSE_HEADER, } from './impersonation-bearer-rotation'; +import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit'; /** * In-memory IDataEngine — same fake as the #4785 session-of-record harness, @@ -77,7 +78,8 @@ export const createMemoryEngine = () => { return out; }; let seq = 0; - return { + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + return bindTestSecurityCatalog({ tables, async insert(name: string, data: any) { const row = { id: data.id ?? `row_${++seq}`, ...data }; @@ -128,7 +130,7 @@ export const createMemoryEngine = () => { tables.set(name, keep); return table.length - keep.length; }, - }; + }, () => catalogFromTables(Object.fromEntries(tables))); }; const SECRET = 'test-secret-at-least-32-chars-long!!'; diff --git a/packages/plugins/plugin-auth/src/organization-add-member.test.ts b/packages/plugins/plugin-auth/src/organization-add-member.test.ts index 35ca9fd274b..8888a0238d5 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -37,6 +37,7 @@ import { AuthPlugin } from './auth-plugin'; import { runOrganizationAddMember } from './organization-add-member.js'; import type { PluginContext } from '@objectstack/core'; import { inviteForAudienceGate } from './audience-gate-test-support'; +import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit'; const SECRET = 'test-secret-at-least-32-chars-long!!'; const BASE = 'http://localhost:3000'; @@ -98,7 +99,8 @@ const createMemoryEngine = () => { return out; }; let seq = 0; - return { + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + return bindTestSecurityCatalog({ tables, async insert(name: string, data: any) { const row = { id: data.id ?? `row_${++seq}`, ...data }; @@ -135,7 +137,7 @@ const createMemoryEngine = () => { tables.set(name, keep); return table.length - keep.length; }, - }; + }, () => catalogFromTables(Object.fromEntries(tables))); }; type MemoryEngine = ReturnType; diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 91ed9c85df6..9c39d82ef65 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -86,6 +86,8 @@ import { AuthPlugin } from './auth-plugin.js'; import { createTenancyService } from './tenancy-service.js'; import type { PluginContext } from '@objectstack/core'; import { inviteForAudienceGate } from './audience-gate-test-support'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; +import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -185,7 +187,9 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); - return engine; + // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which an + // org-less grant of it resolves through (no security plugin binds one here). + return bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); } function makeManager(engine: ObjectQL): AuthManager { From 8dca0b19d543e41314ef87b6bf83d2090a52cca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:17:54 +0000 Subject: [PATCH 09/38] wip: hono-server, sharing and automation fixtures declare the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/current-user-endpoints-additive-baseline.test.ts | 5 +++-- .../src/current-user-endpoints-position-grants.test.ts | 5 +++-- .../plugins/plugin-hono-server/src/server-timing-e2e.test.ts | 5 +++-- .../src/sharing-rule-positions-name-authority.test.ts | 5 +++-- .../src/runas-grant-resolution.integration.test.ts | 3 +++ 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts index de65c62e746..a06c7a1fcfa 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-additive-baseline.test.ts @@ -61,6 +61,7 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit'; const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; const ME_APPS = '/api/v1/me/apps'; @@ -87,14 +88,14 @@ function matches(row: Row, where: Row | undefined): boolean { } function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ find: async (object: string, opts: any) => { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, registry: { getAllApps: () => tables.__apps ?? [], getAllObjects: () => [] }, getSchema: () => undefined, - }; + }, tables); } /** A `sys_permission_set` row, JSON columns stored as text like the real table. */ diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts index 45197491409..8e37c51a1ca 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -38,6 +38,7 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit'; const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; const ME_APPS = '/api/v1/me/apps'; @@ -71,14 +72,14 @@ function matches(row: Row, where: Row | undefined): boolean { * engine. */ function makeQl(tables: Record) { - return { + return bindCatalogFromTables({ find: async (object: string, opts: any, _ctx?: any) => { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, registry: { getAllApps: () => tables.__apps ?? [], getAllObjects: () => [] }, getSchema: () => undefined, - }; + }, tables); } /** A permission set as `sys_permission_set` stores it (JSON columns as text). */ diff --git a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts index 87381544503..96c69a3fea0 100644 --- a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts +++ b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts @@ -17,13 +17,14 @@ import { describe, it, expect } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; import type { PluginContext } from '@objectstack/core'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; /** * Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the * seeded platform admin) vs. a `member_default` grant for `member1`, plus the * `widget` rows the data route returns. Every other read resolves empty. */ -const makeQl = () => ({ +const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { @@ -44,7 +45,7 @@ const makeQl = () => ({ // sys_member, sys_user — nothing to contribute. return []; }, -}); +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); /** Fake auth service: session keyed off the request's `cookie` header. */ const makeAuth = () => ({ diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts index 53dcdfa41bd..fe3ca93f7eb 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts @@ -56,6 +56,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/core'; import { SharingRuleService } from './sharing-rule-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const USER = 'usr_subject'; const OTHER_ORG = 'org_victim'; @@ -79,12 +80,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } /** The permission set that carries the ORG-scoped `manage_sharing` capability. */ diff --git a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts index ac4865baea0..2d30596d215 100644 --- a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts +++ b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts @@ -19,6 +19,7 @@ import { LiteKernel } from '@objectstack/core'; import { AutomationServicePlugin } from './plugin.js'; import { AutomationEngine } from './engine.js'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** * A fake ObjectQL engine that both serves the authz tables (for grant @@ -42,6 +43,8 @@ function fakeObjectQl(tables: Record) { async update(object: string, _f: any, opts: any) { crud.push({ op: 'update', obj: object, ctx: opts?.context }); return { ok: true }; }, async delete(object: string, opts: any) { crud.push({ op: 'delete', obj: object, ctx: opts?.context }); return { ok: true }; }, }; + // [ADR-0131 D3/D4] The security catalog the resolver reads, as the rows convert to it. + bindCatalogFromTables(engine, tables); return { engine, crud }; } From 35e8fcd038d36cd580792696205727e23f89b34e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:25:10 +0000 Subject: [PATCH 10/38] wip: example personas declare permissionSets; spec docs and liveness ledger regenerated Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../references/api/package-api-assembled.mdx | 4 ++-- content/docs/references/identity/position.mdx | 1 + .../src/security/bind-position-sets.ts | 16 +++++++++---- .../app-crm/src/security/sales-positions.ts | 15 ++++++++---- .../app-crm/test/position-bindings.test.ts | 24 +++++++++++++++++++ .../src/security/bind-position-sets.ts | 18 +++++++------- .../app-showcase/src/security/positions.ts | 14 +++++++++++ .../app-showcase/test/inert-wirings.test.ts | 24 +++++++++++++++---- packages/spec/liveness/position.json | 6 +++++ .../spec/liveness/state-counts/position.md | 2 +- 10 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 examples/app-crm/test/position-bindings.test.ts diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 54454ca1bef..cc5ac42cb2f 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -127,7 +127,7 @@ Installed package row whose manifest is the assembled package body | **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | @@ -371,7 +371,7 @@ Installed package row whose manifest is the assembled package body | **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | -| **positions** | `{ name: string; label: string; description?: string; delegatable?: boolean; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | +| **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | | **sharingRules** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Record Sharing Rules | | **apis** | `{ name: string; path: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; summary?: string; … }[]` | optional | API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121) | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Outbound Webhooks | diff --git a/content/docs/references/identity/position.mdx b/content/docs/references/identity/position.mdx index 0a619287f3e..ff1dc3a4d65 100644 --- a/content/docs/references/identity/position.mdx +++ b/content/docs/references/identity/position.mdx @@ -31,6 +31,7 @@ const result = PositionSchema.parse(data); | **name** | `string` | ✅ | Position name, unique per organization (lowercase snake_case) | | **label** | `string` | ✅ | Display label (e.g. VP of Sales) | | **description** | `string` | optional | | +| **permissionSets** | `string[]` | optional | ADR-0131 D3/D4: the permission sets this position distributes, by name (resolved in the environment catalog). | | **delegatable** | `boolean` | optional (default: `false`) | ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false). | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this position. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts index 895404bbd97..e8b9944d199 100644 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ b/examples/app-crm/src/security/bind-position-sets.ts @@ -3,10 +3,16 @@ /** * [#8060] Position ↔ permission-set bindings for the CRM example. * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts` — see - * that file for the full rationale. Short version: the permission model is - * record-authoritative (ADR-0090/0094), bindings live only as - * `sys_position_permission_set` rows, and this app declared three positions + * [ADR-0131 D3/D4] The bindings are DECLARED on the positions + * (`permissionSets` in `sales-positions.ts`), and that is what the + * authorization resolver reads. This binder still writes the same pairs as + * `sys_position_permission_set` rows for the readers that have not moved off + * the junction yet; it is deleted with the last of them, and + * `test/position-bindings.test.ts` holds its list equal to the declarations. + * + * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The + * history: bindings used to live only as `sys_position_permission_set` rows, + * and this app declared three positions * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` * permission set that never met — every persona silently degraded to the * `everyone` baseline (probe-blocked on every CRM object) until an admin @@ -27,7 +33,7 @@ * (ruling on #8060). Only the three declared positions get the set. */ -const BINDINGS: ReadonlyArray = [ +export const BINDINGS: ReadonlyArray = [ ['sales_rep', 'crm_sales_user'], ['sales_manager', 'crm_sales_user'], ['finance_approver', 'crm_sales_user'], diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 9f5bfeb7826..9cad970c3c3 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -5,30 +5,35 @@ import { definePermissionSet } from '@objectstack/spec/security'; /** * Example positions — flat distribution groups for a small sales team - * (positions carry no hierarchy — ADR-0090 D3). + * (positions carry no hierarchy — ADR-0090 D3). Each names the permission sets + * it distributes in `permissionSets` (ADR-0131 D3/D4), which is what the + * authorization resolver reads. */ export const SalesRepPosition = definePosition({ name: 'sales_rep', label: 'Sales Representative', description: 'Front-line sales representative.', + permissionSets: ['crm_sales_user'], }); export const SalesManagerPosition = definePosition({ name: 'sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', + permissionSets: ['crm_sales_user'], }); /** - * Registered in `objectstack.config.ts` (`positions`) and bound to the - * `crm_sales_user` permission set by `bind-position-sets.ts`, alongside - * `sales_rep` and `sales_manager`. This app models no approval flow; the - * worked approval flows live in `examples/app-showcase/src/automation/flows/`. + * Registered in `objectstack.config.ts` (`positions`) and distributing the + * `crm_sales_user` permission set, alongside `sales_rep` and `sales_manager`. + * This app models no approval flow; the worked approval flows live in + * `examples/app-showcase/src/automation/flows/`. */ export const FinanceApproverPosition = definePosition({ name: 'finance_approver', label: 'Finance Approver', description: 'Finance team member authorised to approve discounts above 30%.', + permissionSets: ['crm_sales_user'], }); /** diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts new file mode 100644 index 00000000000..7756be56f1f --- /dev/null +++ b/examples/app-crm/test/position-bindings.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their + * positions (`permissionSets`), which the authorization resolver reads. The + * junction binder (`src/security/bind-position-sets.ts`) still writes the same + * pairs for the readers that have not moved off `sys_position_permission_set`; + * until it is deleted, the two must name the same pairs, or those readers + * judge a position by bindings the resolver does not grant. + */ + +import { describe, expect, it } from 'vitest'; +import { BINDINGS } from '../src/security/bind-position-sets.js'; +import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; + +describe('CRM position bindings', () => { + it('the junction binder writes exactly the bindings the position definitions declare', () => { + const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] + .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) + .sort(); + expect(declared).toHaveLength(3); + expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + }); +}); diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts index 72bcdde3c1d..645ccd00166 100644 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ b/examples/app-showcase/src/security/bind-position-sets.ts @@ -3,10 +3,13 @@ /** * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. * - * The permission model is record-authoritative (ADR-0090/0094): bindings live - * only as `sys_position_permission_set` rows. A fresh deploy used to boot with - * ZERO bindings — every persona silently degraded to the `everyone` baseline - * until an admin hand-assigned all sets. + * [ADR-0131 D3/D4] The bindings are DECLARED on the positions + * (`permissionSets` in `positions.ts`), and that is what the authorization + * resolver reads. This binder still writes the same pairs as + * `sys_position_permission_set` rows for the readers that have not moved off + * the junction yet (the delegated-admin gate, the explainer, the sharing + * recipients); it is deleted with the last of them. + * `test/inert-wirings.test.ts` holds this list equal to the declarations. * * This cannot be a declarative SEED: the seed loader runs before the security * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name @@ -30,11 +33,8 @@ */ /** - * The persona bindings, exported because they are the only place that answers - * "which permission sets does a position actually hold?" — the question a - * sharing rule with a `position` recipient has to answer before its grant can - * mean anything (a share row for a principal with no object-level `allowRead` - * is never consulted). `inert-wirings.test.ts` §6 reads this list. + * The persona bindings as junction rows — the same pairs the position + * definitions declare (`inert-wirings.test.ts` pins the equality). */ export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ ['contributor', 'showcase_contributor'], diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 39e0661c5de..297cd2e2401 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -9,6 +9,12 @@ * src/data/seed/), and the manager chain is `sys_user.manager_id` * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * + * Each position names the permission sets it distributes in `permissionSets` + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. + * `src/security/bind-position-sets.ts` still writes the same bindings as + * `sys_position_permission_set` rows, for the readers that have not moved off + * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, * ADR-0090 D5) and `guest` (implicitly held by anonymous visitors, @@ -23,6 +29,7 @@ export const ContributorPosition = definePosition({ name: 'contributor', label: 'Contributor', description: 'Works tasks on their own projects.', + permissionSets: ['showcase_contributor'], }); /** Runs a unit: depth-scoped visibility over the unit's private records. */ @@ -30,6 +37,7 @@ export const ManagerPosition = definePosition({ name: 'manager', label: 'Project Manager', description: 'Manages projects and the contributors on them.', + permissionSets: ['showcase_manager'], }); /** Org-wide read for reporting — depth-based (`readScope: org`), not VAMA. */ @@ -37,6 +45,7 @@ export const ExecPosition = definePosition({ name: 'exec', label: 'Executive', description: 'Read-all visibility for reporting.', + permissionSets: ['showcase_executive'], }); /** Compliance: View-All bypass (VAMA) — reads everything, changes nothing. */ @@ -44,6 +53,7 @@ export const AuditorPosition = definePosition({ name: 'auditor', label: 'Auditor', description: 'Compliance read-only view across private records (viewAllRecords).', + permissionSets: ['showcase_auditor'], }); /** Back-office: system permissions, Modify-All repairs, the Operations app. */ @@ -51,6 +61,7 @@ export const OpsPosition = definePosition({ name: 'ops', label: 'Operations', description: 'Back-office operations — Setup access, announcement repairs, Operations app.', + permissionSets: ['showcase_ops'], }); /** @@ -62,6 +73,7 @@ export const FieldOpsDelegatePosition = definePosition({ name: 'field_ops_delegate', label: 'Field Ops Delegate Admin', description: 'Scoped administration of the Field Operations business-unit subtree.', + permissionSets: ['showcase_field_ops_delegate'], }); /** @@ -74,6 +86,7 @@ export const ClientLiaisonPosition = definePosition({ name: 'client_liaison', label: 'Client Liaison', description: 'Prepares and publishes client-facing project briefs; cannot see internal budget figures.', + permissionSets: ['showcase_client_liaison'], }); /** @@ -86,6 +99,7 @@ export const ClientPortalUserPosition = definePosition({ name: 'client_portal_user', label: 'Client Portal User', description: 'External client admitted to the Client Portal.', + permissionSets: ['showcase_guest_portal'], }); /** diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 2ef7fbfea1a..1af7f7ca9f0 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -680,12 +680,18 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 sharingModel?: string; }>; const sets = ((stack as { permissions?: unknown[] }).permissions ?? []) as AuthoredSet[]; + const positions = ((stack as { positions?: unknown[] }).positions ?? []) as Array<{ + name: string; + permissionSets?: string[]; + }>; - /** The permission sets a holder of `position` effectively carries. */ + /** + * The permission sets a holder of `position` effectively carries: the ones + * its definition names (`permissionSets`, ADR-0131 D3/D4 — what the + * authorization resolver reads). + */ function setsHeldBy(position: string): AuthoredSet[] { - const held = new Set( - POSITION_PERMISSION_SET_BINDINGS.filter(([p]) => p === position).map(([, s]) => s), - ); + const held = new Set(positions.find((p) => p.name === position)?.permissionSets ?? []); // Every authenticated member also holds the `everyone` baseline (the // `isDefault` set, ADR-0090 D5) IN ADDITION to their explicit grants. return sets.filter((s) => held.has(s.name) || s.isDefault === true); @@ -695,6 +701,16 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 expect(rules.length).toBeGreaterThan(0); }); + // [ADR-0131 D3/D4] The junction binder still writes the same bindings for the + // readers that have not moved off `sys_position_permission_set`; until it is + // deleted the two must name the same pairs, or those readers judge a + // position by bindings the resolver does not grant. + it('the junction binder writes exactly the bindings the position definitions declare', () => { + const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); + expect(declared.length).toBeGreaterThan(0); + expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + }); + it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { // `effectiveSharingModel` maps BOTH of these to 'public'; the // `controlled_by_parent` case has its own refusal ("share the master diff --git a/packages/spec/liveness/position.json b/packages/spec/liveness/position.json index 20c60adfd6b..641aa3ae7c5 100644 --- a/packages/spec/liveness/position.json +++ b/packages/spec/liveness/position.json @@ -20,6 +20,12 @@ "evidence": "rendered on the sys_position record detail in Setup's generic object views", "note": "display." }, + "permissionSets": { + "status": "live", + "verifiedAt": "2026-10-10", + "evidence": "packages/core/src/security/resolve-authz-context.ts#positionSetNamesOf", + "note": "ADR-0131 D3/D4: the position -> permission-set binding. resolveUserAuthzGrants (section 6a) reads each held position's catalog definition and grants the sets its permissionSets names, by name; the sys_position_permission_set junction is no longer read. Pinned by resolve-authz-context.grant-set-by-name.test.ts and the batch-equivalence golden." + }, "delegatable": { "status": "live", "verifiedAt": "2026-07-30", diff --git a/packages/spec/liveness/state-counts/position.md b/packages/spec/liveness/state-counts/position.md index 1d47113ce27..362a194588e 100644 --- a/packages/spec/liveness/state-counts/position.md +++ b/packages/spec/liveness/state-counts/position.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `position` | 12 | 0 | 0 | 0 | 0 | 12 | +| `position` | 13 | 0 | 0 | 0 | 0 | 13 | From 58d33e5505c3d7a60f89d85bb57c8608cbac8f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:26:12 +0000 Subject: [PATCH 11/38] fix(plugin-security): a Setup row edit carries the definition's permissionSets through the write-through Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/position-write-through.test.ts | 18 +++++++++++++++ .../src/position-write-through.ts | 23 +++++++++++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 51eded43bab..1f637cfc087 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -288,6 +288,24 @@ describe('[ADR-0131 D3] position write-through under single — a Setup position expect((after.grants as any).permissions).not.toContain('pw_reports'); }); + // [ADR-0131 D3/D4] `permissionSets` has no row column: a Setup edit of the + // row saves the definition again, and must carry the binding the metadata + // door declared rather than clear it. + it('a row edit keeps the `permissionSets` the stored definition names, and a rename carries them to the new name', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); + await (b.protocol as any).saveMetaItem({ + type: 'position', name: 'night_lead', item: { name: 'night_lead', label: 'Night lead', permissionSets: ['night_tools'] }, + }); + expect((await b.envRows('night_lead')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); + await patch(b, created.id, { label: 'Night supervisor' }); + expect((await b.envRows('night_lead')).map((r) => r.body)).toEqual([ + { name: 'night_lead', label: 'Night supervisor', delegatable: false, permissionSets: ['night_tools'] }, + ]); + await patch(b, created.id, { name: 'night_supervisor' }); + expect((await b.envRows('night_supervisor')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); + }); + it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { const b = await boot(); const created: any = await create(b, { name: 'shift_lead', label: 'Shift lead' }); diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index 17ac65e2093..ac350354b74 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -296,13 +296,26 @@ export function createPositionWriteThrough( const environmentOwns = (name: unknown): name is string => typeof name === 'string' && name !== '' && !packageHoldsPosition(ql, name); - const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string) => - door.saveMetaItem({ + /** + * [ADR-0131 D3/D4] Save the definition a row carries. `permissionSets` has no + * row column, so the list the stored definition under `carriedFrom` names + * (the row's name before this write) travels into the saved body — a row + * edit never clears a binding the metadata door declared. A registry that + * cannot be read throws, and the caller undoes the row write. + */ + const saveDefinition = (door: PositionMetadataDoor, row: Record, actor?: string, carriedFrom?: unknown) => { + const item = positionBodyFromRow(row); + const stored = typeof carriedFrom === 'string' && carriedFrom !== '' + ? (ql?.registry?.getItem?.(POSITION_METADATA_TYPE, carriedFrom) as { permissionSets?: unknown } | undefined)?.permissionSets + : undefined; + if (Array.isArray(stored) && stored.length > 0) item.permissionSets = [...stored]; + return door.saveMetaItem({ type: POSITION_METADATA_TYPE, name: String(row.name), - item: positionBodyFromRow(row), + item, ...(actor ? { actor } : {}), }); + }; /** * Delete `name`'s environment definition once no row carries the name any @@ -460,7 +473,7 @@ export function createPositionWriteThrough( // stage and can have no definition — the edit stays a row write. if (!renamed && !metadataDoorAcceptsPositionName(post.name)) continue; try { - await saveDefinition(door, post, actor); + await saveDefinition(door, post, actor, pre.name); saved.push(pair); } catch (e) { await undoUpdate(targets, patch); @@ -501,7 +514,7 @@ export function createPositionWriteThrough( if (pre.name !== post.name) { await door.deleteMetaItem({ type: POSITION_METADATA_TYPE, name: String(post.name), ...(actor ? { actor } : {}) }); } else { - await saveDefinition(door, pre, actor); + await saveDefinition(door, pre, actor, pre.name); } } catch (e) { logError( From 4362ea1c8964342437371d6903e8c58f9cd84dc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:29:27 +0000 Subject: [PATCH 12/38] wip: cloud-connection fixtures declare the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/install-local-principal.fixtures.ts | 21 ++++- ...stall-local-capability-enumeration.test.ts | 5 +- ...etplace-install-local-list-posture.test.ts | 3 +- ...e-install-local-listing-not-loaded.test.ts | 7 +- ...stall-local-no-active-organization.test.ts | 7 +- ...stall-local-sample-data-not-loaded.test.ts | 7 +- ...ce-install-local-tenancy-admission.test.ts | 5 +- 8 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 packages/cloud-connection/src/__tests__/security-catalog.testkit.ts diff --git a/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index 9bd1cb27214..df763f37b8b 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -36,9 +36,17 @@ * that some callers are not. */ +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; + /** The default fixture user id — matches what the suites already asserted on. */ export const INSTALLER_USER_ID = 'admin'; +/** + * [ADR-0131 D3/D4] The installer's set as the security catalog declares it — + * the resolver reads a set's body from its definition, never from the row. + */ +export const INSTALLER_SET = { name: 'admin_full_access', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }; + /** * The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the * way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set` @@ -87,11 +95,22 @@ export function withInstallerGrants>( ): T { const rows = installerGrantRows(userId); const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined; - return { + const wrapped = { ...engine, find: async (object: string, options?: unknown) => { if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; return inner ? inner(object, options) : []; }, } as T; + // What the security plugin binds to a real engine: the catalog the + // installer's grant names its set in. + bindSecurityCatalogReader(wrapped, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => (type === 'permission' && name === INSTALLER_SET.name ? INSTALLER_SET : undefined), + listItems: (type) => (type === 'permission' ? [INSTALLER_SET] : []), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return wrapped; } diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts index 1ca5d5a4d5d..814e0b3a10c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -87,6 +87,7 @@ vi.mock('@objectstack/runtime', () => ({ import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -191,11 +192,11 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register }, auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, - objectql: { + objectql: bindCatalogFromTables({ syncSchemas, find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), delete: engineDelete, - }, + }, rows), metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, }; const ctx: any = { diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index f15e760c5d5..cbd901f1ae9 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -69,6 +69,7 @@ import { tmpdir } from 'node:os'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource } from './local-manifest-source.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -163,7 +164,7 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register: vi.fn() }, auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, - objectql: { syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, + objectql: bindCatalogFromTables({ syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, rows), metadata: {}, }; const ctx: any = { diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index 55a1a6970ee..8a72556b500 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -43,7 +43,8 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; +import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; type Row = Record & { id: string }; @@ -111,7 +112,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di const reads: string[] = []; const caller = { as: 'operator' as 'operator' | 'member' }; const grants = () => (caller.as === 'operator' ? installerGrantRows() : MEMBER_GRANTS); - const engine = { + const engine = bindTestSecurityCatalog({ syncSchemas: vi.fn(async () => undefined), registry: { getAllPackages: () => [] }, async find(object: string, query?: any): Promise { @@ -131,7 +132,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di })); return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); }, - }; + }, { permissions: [INSTALLER_SET, { name: 'organization_admin', systemPermissions: ['setup.access', 'manage_org_users'] }] }); const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const services: Record = { manifest: { register }, diff --git a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts index 17bfa8130d7..cfeff0f42df 100644 --- a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts @@ -61,8 +61,9 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { INSTALLER_USER_ID, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { INSTALLER_USER_ID, withInstallerGrants, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -116,7 +117,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string } syncSchemas: async () => undefined, find: async (object: string, query?: any) => { reads.push({ object, query }); return []; }, }); - const objectql = { + const objectql = bindTestSecurityCatalog({ ...granted, find: async (object: string, query?: any) => { if (object === 'sys_member' || object === 'sys_organization_member') { @@ -125,7 +126,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string } } return granted.find(object, query); }, - }; + }, { permissions: [INSTALLER_SET] }); const register = vi.fn(); const services: Record = { manifest: { register }, diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index d6fa378d17f..8d88fcffcc8 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -49,7 +49,8 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; +import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; type Row = Record & { id: string }; @@ -131,7 +132,7 @@ async function restartWith( if (!objects.has(object)) throw new Error(`Object '${object}' not found`); return (tables[object] ??= []); }; - const engine = { + const engine = bindTestSecurityCatalog({ syncSchemas: vi.fn(async () => undefined), registry: { getAllPackages: () => [] }, async find(object: string, query?: any): Promise { @@ -164,7 +165,7 @@ async function restartWith( writes.push(`delete ${object}#${String(dispatch.id)}`); return true; }, - }; + }, { permissions: [INSTALLER_SET] }); const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const i18n = { loadTranslations: vi.fn() }; const services: Record = { diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts index 30c9c4495b6..781a312041a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts @@ -67,6 +67,7 @@ import { AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; const LEDGER_FILE = 'com.acme.tenancy.json'; @@ -157,14 +158,14 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, ], }; - return { + return bindCatalogFromTables({ syncSchemas: opts.syncSchemas, find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row) => matchesWhere(row, q?.where)); // The caller's bound is held BY PRESENCE, never re-derived here. return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } type Tenancy = From e9fc856b95dfa07beb44fe56c7e8a9c7ea219f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:31:24 +0000 Subject: [PATCH 13/38] wip: service-datasource, service-settings and approvals fixtures declare the catalog Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../approval-positions-name-authority.test.ts | 5 +- ...al-tenant-positions-name-authority.test.ts | 5 +- ...n-routes-tenancy-posture-admission.test.ts | 5 +- .../src/__tests__/entitled-caller.fixture.ts | 15 ++- .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ .../src/__tests__/security-catalog.testkit.ts | 92 +++++++++++++++++++ ...settings-admission-tenancy-posture.test.ts | 3 + 8 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts create mode 100644 packages/services/service-settings/src/__tests__/security-catalog.testkit.ts diff --git a/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts index 10c21d1a5db..2ece3a5c3f4 100644 --- a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts @@ -56,6 +56,7 @@ import { hasPlatformAdminStanding, resolveUserAuthzGrants } from '@objectstack/c import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import type { ApprovalRequestRow } from '@objectstack/spec/contracts'; import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; interface FakeRow { [k: string]: any } @@ -141,12 +142,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'name-only' | 'genuine' | 'plain'; diff --git a/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts b/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts index ae455f41d09..c6861a57c21 100644 --- a/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-tenant-positions-name-authority.test.ts @@ -75,6 +75,7 @@ import { resolveUserAuthzGrants } from '@objectstack/core'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import type { ApprovalRequestRow } from '@objectstack/spec/contracts'; import { ApprovalService, type ApprovalNodeAutoOutcome } from './approval-service.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; interface FakeRow { [k: string]: any } @@ -156,12 +157,12 @@ function makeAuthzQl(tables: Record>>) { if (v === null) return (row[k] ?? null) === null; return row[k] === v; }); - return { + return bindCatalogFromTables({ async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; }, - }; + }, tables); } type Shape = 'row-org-owner' | 'row-org-admin' | 'genuine' | 'plain'; diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts index b628991ecf6..1b71dda3190 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts @@ -54,6 +54,7 @@ import { ObjectKernel, hashApiKey } from '@objectstack/core'; import type { PluginContext } from '@objectstack/core'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { registerDatasourceAdminRoutes, DATASOURCE_ADMIN_CAPABILITY } from '../admin-routes.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; const RAW_MEMBER_KEY = 'osk_15350_member'; const RAW_EXMEMBER_KEY = 'osk_15350_exmember'; @@ -143,12 +144,12 @@ function makeQl() { }, ], }; - return { + return bindCatalogFromTables({ find: async (object: string, q: any = {}) => { const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - }; + }, tables); } // --------------------------------------------------------------------------- diff --git a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts index c6196924636..2be8019e0bc 100644 --- a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts +++ b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts @@ -30,6 +30,7 @@ * makes them worth having. */ +import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core'; import { DATASOURCE_ADMIN_CAPABILITY } from '../admin-routes.js'; /** The credential that resolves to a caller holding the capability. */ @@ -92,7 +93,7 @@ export function createSessionAuthService() { * rather than a validity edge case that belongs to `@objectstack/core`'s suite. */ export function createGrantsEngine() { - return { + const engine = { find: async (object: string, opts: any) => { if (object === 'sys_user_permission_set') { return opts?.where?.user_id === ENTITLED_USER @@ -125,4 +126,16 @@ export function createGrantsEngine() { return []; }, }; + // [ADR-0131 D3/D4] The set as the security catalog declares it — the resolver + // reads a set's body from its definition, never from the row above. + const definition = { name: GRANT_SET_NAME, systemPermissions: [DATASOURCE_ADMIN_CAPABILITY] }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => (type === 'permission' && name === GRANT_SET_NAME ? definition : undefined), + listItems: (type) => (type === 'permission' ? [definition] : []), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; } diff --git a/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts new file mode 100644 index 00000000000..b023f68caac --- /dev/null +++ b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: bind a security catalog to a fake engine. + * + * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets + * they name and the set bodies from the catalog the security plugin binds to + * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, + * so it binds one here, over the definitions it declares. A row alone grants + * nothing any more: the set or position a fixture grants must be declared. + */ + +import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; + +type Definition = Record; + +/** The definitions a suite declares, per catalog type. */ +export interface TestSecurityCatalog { + positions?: readonly Definition[]; + permissions?: readonly Definition[]; +} + +/** + * Bind a catalog over `catalog`'s definitions to `engine` and return the + * engine. Given a function, the definitions are read afresh on every lookup. + */ +export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { + const current = typeof catalog === 'function' ? catalog : () => catalog; + const itemsOf = (type: string): readonly Definition[] => { + const c = current(); + return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + }; + bindSecurityCatalogReader(engine, createSecurityCatalogReader({ + registry: { + getItem: (type, name) => itemsOf(type).find((d) => d.name === name), + listItems: (type) => itemsOf(type), + isPackageDisabled: () => false, + }, + metadata: { get: () => undefined, list: () => [] }, + })); + return engine; +} + +const parsed = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +/** + * The definitions a row-shaped fixture's catalog rows convert to: each + * `sys_permission_set` row's body becomes a set definition (the first row of a + * name wins), and the `sys_position_permission_set` rows become each + * position's `permissionSets` through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. A position name the conversion + * reports `conflicting` names no set. + */ +export function catalogFromTables(tables: Record): TestSecurityCatalog { + const setRows = (tables.sys_permission_set ?? []) as Definition[]; + const permissions: Definition[] = []; + for (const row of setRows) { + if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; + const definition: Definition = { name: row.name }; + for (const [column, key] of [ + ['system_permissions', 'systemPermissions'], + ['tab_permissions', 'tabPermissions'], + ['object_permissions', 'objects'], + ] as const) { + const value = parsed(row[column] ?? row[key]); + if (value !== undefined && value !== null) definition[key] = value; + } + permissions.push(definition); + } + const conversion = convertPositionBindingRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: setRows, + bindings: (tables.sys_position_permission_set ?? []) as Definition[], + }); + const positions: Definition[] = []; + for (const fate of conversion.positions) { + if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); + } + return { positions, permissions }; +} + +/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +export function bindCatalogFromTables(engine: T, tables: Record): T { + return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); +} diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index a505a0918c5..c684fda6143 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -73,6 +73,7 @@ import { AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; import type { SettingsContext } from './settings-service.types.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; /** * The seam's own return value, captured through a PASS-THROUGH of the real @@ -340,6 +341,8 @@ async function mount(tenancy: Tenancy): Promise { // The caller's bound is held BY PRESENCE, never re-derived here. return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }; + // [ADR-0131 D3/D4] The security catalog the resolver reads, as those rows convert to it. + bindCatalogFromTables(engine, PERMISSION_TABLES); const http = new MockHttp(); From 4a2d6862d1e94260c877ad13fa74055a92b3eb22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:32:54 +0000 Subject: [PATCH 14/38] wip: changeset; pin the walled promotion path through the metadata door Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../15204-s1-position-permission-sets.md | 43 +++++++++++++++++++ .../src/position-write-through.test.ts | 15 +++++++ 2 files changed, 58 insertions(+) create mode 100644 .changeset/15204-s1-position-permission-sets.md diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md new file mode 100644 index 00000000000..a8673e3bfe5 --- /dev/null +++ b/.changeset/15204-s1-position-permission-sets.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-auth': minor +--- + +feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + +Clause-②: yes + + + +**BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + +**FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + +```ts +definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); +``` + +The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + +**`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + +**`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + +- A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. +- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing. +- Deactivation is unchanged. A `sys_position` row or a `sys_permission_set` row with `active: false` still confers nothing: the row is read for that flag and nothing else. For a set, that is the active organization's own row, else the organization-less row. A name with no row carries no flag. +- Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. +- An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. +- `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + +**`@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's exclusion ledger drops the set-row columns the resolver no longer reads (`id`, `system_permissions`, `tab_permissions` and their camel-case spellings). + +**What stops granting at upgrade, and what to do.** + +- **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. +- **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. +- **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 1f637cfc087..0cf67903d2e 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -646,6 +646,21 @@ describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it expect(await b.envRows('plant_lead')).toEqual([]); }); + // [ADR-0131 D3, #15196 Q3 = A] The promotion path the release note names: + // under a wall an organization's Setup position is a row with no definition, + // so it grants nothing; the metadata door takes an environment definition + // under the same name, and that definition is what the catalog then serves. + it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { + const b = await boot({ posture: 'isolated' }); + await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); + expect(await b.catalogResolves('plant_auditor')).toBe(false); + await (b.protocol as any).saveMetaItem({ + type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, + }); + expect((await b.envRows('plant_auditor')).map((r) => r.body.permissionSets)).toEqual([['plant_tools']]); + expect(await b.catalogResolves('plant_auditor')).toBe(true); + }); + it('a system write is never translated', async () => { const b = await boot(); await b.engine.insert('sys_position', { id: 'pos_sys', name: 'seeded_lead', label: 'Seeded' }, { context: SYS } as any); From af4aa341d183654a8a75c3b2ebf8e9a21fa49eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:52:20 +0000 Subject: [PATCH 15/38] test: compact the per-package security-catalog test helper Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/security/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ .../src/__tests__/security-catalog.testkit.ts | 60 +++++++------------ 11 files changed, 220 insertions(+), 440 deletions(-) diff --git a/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts +++ b/packages/cloud-connection/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-approvals/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-auth/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-hono-server/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-sharing/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/rest/src/__tests__/security-catalog.testkit.ts b/packages/rest/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/rest/src/__tests__/security-catalog.testkit.ts +++ b/packages/rest/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/runtime/src/security/security-catalog.testkit.ts b/packages/runtime/src/security/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/runtime/src/security/security-catalog.testkit.ts +++ b/packages/runtime/src/security/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts +++ b/packages/services/service-automation/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts +++ b/packages/services/service-datasource/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } diff --git a/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts index b023f68caac..a4c173ebf20 100644 --- a/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts +++ b/packages/services/service-settings/src/__tests__/security-catalog.testkit.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Test scaffolding: bind a security catalog to a fake engine. - * - * `resolveUserAuthzGrants` (`@objectstack/core`) reads positions, the sets - * they name and the set bodies from the catalog the security plugin binds to - * its engine (ADR-0131 D3/D4). A suite whose engine is a double has no plugin, - * so it binds one here, over the definitions it declares. A row alone grants - * nothing any more: the set or position a fixture grants must be declared. + * Test scaffolding: bind a security catalog to a fake engine (ADR-0131 D3/D4). + * `resolveUserAuthzGrants` reads positions, their sets and the set bodies from + * the catalog the security plugin binds to its engine; a suite whose engine is + * a double binds one here. A row alone grants nothing: what a fixture grants + * must be declared. {@link catalogFromTables} declares a row-shaped fixture's + * catalog, its junction rows through `convertPositionBindingRows` — the + * conversion the upgrade ceremony applies. */ import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; @@ -20,22 +20,14 @@ export interface TestSecurityCatalog { permissions?: readonly Definition[]; } -/** - * Bind a catalog over `catalog`'s definitions to `engine` and return the - * engine. Given a function, the definitions are read afresh on every lookup. - */ +/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */ export function bindTestSecurityCatalog(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T { - const current = typeof catalog === 'function' ? catalog : () => catalog; - const itemsOf = (type: string): readonly Definition[] => { - const c = current(); - return type === 'position' ? (c.positions ?? []) : type === 'permission' ? (c.permissions ?? []) : []; + const items = (type: string): readonly Definition[] => { + const c = typeof catalog === 'function' ? catalog() : catalog; + return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? []; }; bindSecurityCatalogReader(engine, createSecurityCatalogReader({ - registry: { - getItem: (type, name) => itemsOf(type).find((d) => d.name === name), - listItems: (type) => itemsOf(type), - isPackageDisabled: () => false, - }, + registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false }, metadata: { get: () => undefined, list: () => [] }, })); return engine; @@ -50,43 +42,31 @@ const parsed = (value: unknown): unknown => { } }; -/** - * The definitions a row-shaped fixture's catalog rows convert to: each - * `sys_permission_set` row's body becomes a set definition (the first row of a - * name wins), and the `sys_position_permission_set` rows become each - * position's `permissionSets` through `convertPositionBindingRows` — the - * conversion the upgrade ceremony applies. A position name the conversion - * reports `conflicting` names no set. - */ +/** The definitions a fixture's catalog rows convert to (first set row of a name wins; a `conflicting` position names no set). */ export function catalogFromTables(tables: Record): TestSecurityCatalog { const setRows = (tables.sys_permission_set ?? []) as Definition[]; const permissions: Definition[] = []; for (const row of setRows) { if (typeof row.name !== 'string' || permissions.some((d) => d.name === row.name)) continue; const definition: Definition = { name: row.name }; - for (const [column, key] of [ - ['system_permissions', 'systemPermissions'], - ['tab_permissions', 'tabPermissions'], - ['object_permissions', 'objects'], - ] as const) { + for (const [column, key] of [['system_permissions', 'systemPermissions'], ['tab_permissions', 'tabPermissions'], ['object_permissions', 'objects']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } permissions.push(definition); } - const conversion = convertPositionBindingRows({ + const { positions } = convertPositionBindingRows({ positions: (tables.sys_position ?? []) as Definition[], permissionSets: setRows, bindings: (tables.sys_position_permission_set ?? []) as Definition[], }); - const positions: Definition[] = []; - for (const fate of conversion.positions) { - if (fate.fate !== 'conflicting') positions.push({ name: fate.name, permissionSets: [...fate.permissionSets] }); - } - return { positions, permissions }; + return { + permissions, + positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + }; } -/** Bind the catalog `tables`' rows convert to, re-derived on every lookup so later writes are in it. */ +/** Bind the catalog `tables` convert to, re-derived per lookup so a row written later is in it. */ export function bindCatalogFromTables(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } From 15f5c97f3cb51546f5d76c239cb6f8760aa41792 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:53:36 +0000 Subject: [PATCH 16/38] spec: register the position-permission-sets-declared ADR-0087 semantic entry Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- .../15204-s1-position-permission-sets.md | 2 +- .../18.position-permission-sets-declared.ts | 37 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 33 +++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md index a8673e3bfe5..10b8874e1b4 100644 --- a/.changeset/15204-s1-position-permission-sets.md +++ b/.changeset/15204-s1-position-permission-sets.md @@ -9,7 +9,7 @@ feat(spec,core)!: a position's permission sets are declared in `permissionSets` Clause-②: yes - + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. diff --git a/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts b/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts new file mode 100644 index 00000000000..f47c7ca2242 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3/D4 — a runtime narrowing with an authorable key ADDED: the +// position -> permission-set binding moves from the junction rows onto the +// position definition. Registered because an app or a deployment that bound +// sets to positions with rows must name them on the definitions instead. +export const entry: SemanticMigration = { + id: 'position-permission-sets-declared', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_position_permission_set rows that bound permission sets to a position — written by an ' + + 'app binder at boot, by Setup on a position page, or by confirming an audience-binding suggestion — ' + + 'which the authorization resolver no longer reads', + replacement: + 'name the sets in `permissionSets` on the position definition, for example ' + + '`definePosition({ name: \'sales_rep\', label: \'Sales Representative\', permissionSets: [\'crm_sales_user\'] })`. ' + + 'An app declares it in code; a deployment saves the definition through the metadata door ' + + '(`PUT /api/v1/meta/position/:name`, a platform administrator). Rows already stored are converted by the ' + + 'ADR-0131 upgrade ceremony, which applies `convertPositionBindingRows` (`@objectstack/core`)', + reason: + 'ADR-0131 D3/D4: the security catalog has one home, the environment registry, and every reference to a ' + + 'catalog item is a name resolved there. A position now grants the sets its definition names and the ' + + 'sets resolve by name, bodies included, so a binding that exists only as a junction row grants nothing. ' + + 'The conversion of stored rows is data, not a metadata rewrite: organizations that bound different ' + + 'sets to one position name are reported conflicting and never merged (D10 fate 4), so no chain step ' + + 'can apply it on its own. Under a wall a position or set an organization authored in Setup has no ' + + 'definition at all and stops granting (ruling #15196 Q3 = A); a platform administrator re-creates it as ' + + 'environment metadata under the same name to keep it. ADR-0087.', + acceptanceCriteria: + 'Every position whose holders should hold a permission set names that set in `permissionSets` on its ' + + 'definition (`GET /api/v1/meta/position/:name` shows it), and a holder of the position resolves the ' + + 'set: it is listed in the `permissions` of the holder\'s authorization context. A junction row with no ' + + 'matching `permissionSets` entry grants nothing.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0a7af06e22a..93caff74c5e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -18306,6 +18306,39 @@ const step18: MigrationStep = { + 'row, no lockfile pin. ⛔ Do not repair one by widening the check back — the grammar ' + 'is the contract now, on nine carriers at once.', }, + // ADR-0131 D3/D4 — a runtime narrowing with an authorable key ADDED: the + // position -> permission-set binding moves from the junction rows onto the + // position definition. Registered because an app or a deployment that bound + // sets to positions with rows must name them on the definitions instead. + { + id: 'position-permission-sets-declared', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_position_permission_set rows that bound permission sets to a position — written by an ' + + 'app binder at boot, by Setup on a position page, or by confirming an audience-binding suggestion — ' + + 'which the authorization resolver no longer reads', + replacement: + 'name the sets in `permissionSets` on the position definition, for example ' + + '`definePosition({ name: \'sales_rep\', label: \'Sales Representative\', permissionSets: [\'crm_sales_user\'] })`. ' + + 'An app declares it in code; a deployment saves the definition through the metadata door ' + + '(`PUT /api/v1/meta/position/:name`, a platform administrator). Rows already stored are converted by the ' + + 'ADR-0131 upgrade ceremony, which applies `convertPositionBindingRows` (`@objectstack/core`)', + reason: + 'ADR-0131 D3/D4: the security catalog has one home, the environment registry, and every reference to a ' + + 'catalog item is a name resolved there. A position now grants the sets its definition names and the ' + + 'sets resolve by name, bodies included, so a binding that exists only as a junction row grants nothing. ' + + 'The conversion of stored rows is data, not a metadata rewrite: organizations that bound different ' + + 'sets to one position name are reported conflicting and never merged (D10 fate 4), so no chain step ' + + 'can apply it on its own. Under a wall a position or set an organization authored in Setup has no ' + + 'definition at all and stops granting (ruling #15196 Q3 = A); a platform administrator re-creates it as ' + + 'environment metadata under the same name to keep it. ADR-0087.', + acceptanceCriteria: + 'Every position whose holders should hold a permission set names that set in `permissionSets` on its ' + + 'definition (`GET /api/v1/meta/position/:name` shows it), and a holder of the position resolves the ' + + 'set: it is listed in the `permissions` of the holder\'s authorization context. A junction row with no ' + + 'matching `permissionSets` entry grants nothing.', + }, // A write-door answer, not an authorable key: there is no D2 conversion and // nothing for `objectstack migrate meta` to rewrite. The sibling of // `18.by-id-write-unreadable-row-not-found`, for the predicate door. The entry From 7564191e2ac6c17fd5bc69c3546af4fa71ba4efd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:15:28 +0000 Subject: [PATCH 17/38] spec: the position form offers permissionSets Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U Co-authored-by: Claude --- packages/spec/src/identity/position.form.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/identity/position.form.ts b/packages/spec/src/identity/position.form.ts index 005bee8eb5d..b103938a0ae 100644 --- a/packages/spec/src/identity/position.form.ts +++ b/packages/spec/src/identity/position.form.ts @@ -6,7 +6,7 @@ import { defineForm } from '../ui/view.zod'; * Position — canonical FormView layout. * * Positions are flat capability-distribution groups (ADR-0090 D3): name, - * label, optional description. No hierarchy — the org tree lives on + * label, optional description, and the permission sets they distribute. No hierarchy — the org tree lives on * business units. Single section is enough; the FormView gives the editor * friendly labels and required markers. */ @@ -17,12 +17,15 @@ export const positionForm = defineForm({ { label: 'Position', description: - 'A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree.', + 'A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager): it names the sets it distributes. Capability lives on permission sets; visibility depth lives on the business-unit tree.', columns: 2, fields: [ { field: 'name', required: true, helpText: 'snake_case' }, { field: 'label', required: true }, { field: 'description', colSpan: 2 }, + // [ADR-0131 D3/D4] The sets a holder of this position holds, by name — + // what the authorization resolver reads (the junction rows grant nothing). + { field: 'permissionSets', type: 'tags', colSpan: 2, helpText: 'Permission sets this position distributes, by name (snake_case).' }, // #19331 — `delegatable` is declared by PositionSchema and was offered by // no control, so the only door to an ADR-0091 D3 self-service delegation // was the Source tab's free-text JSON. A boolean node takes the boolean From 56d49495622eb7045b72081db87fa3c5a407ae13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:39:12 +0000 Subject: [PATCH 18/38] core: deactivation of positions and permission sets is read from the activation ledger The resolver honours ADR-0049 deactivation through sys_metadata_activation (types position and permission) and no longer reads the catalog row's active column. convertDeactivatedCatalogRows turns existing active=false rows into ledger rows for the upgrade ceremony; it is not wired into boot. A composition whose registry has no ledger object issues no ledger read. The admin-standing surface now derives from the ledger; the last-admin guard refuses a ledger write that switches admin_full_access off. The name-fold warning names permissionSets as the governed remedy. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- ...authz-context.batch-equivalence.testkit.ts | 7 +- .../__tests__/security-catalog.testkit.ts | 17 +- .../security/admin-standing-surface.test.ts | 9 +- .../src/security/admin-standing-surface.ts | 39 +-- packages/core/src/security/index.ts | 4 + .../position-binding-conversion.test.ts | 32 ++- .../security/position-binding-conversion.ts | 66 +++++ ...uthz-context.batch-equivalence.golden.json | 225 ++++++++---------- ...ve-authz-context.batch-equivalence.test.ts | 46 ++-- ...ve-authz-context.grant-set-by-name.test.ts | 67 ++++-- ...-authz-context.request-grants-memo.test.ts | 51 ++-- .../security/resolve-authz-context.test.ts | 78 ++++-- .../src/security/resolve-authz-context.ts | 217 +++++++---------- .../src/security/resolve-user-grants-cache.ts | 3 + ...last-admin-guard.activation-ledger.test.ts | 58 +++++ .../plugin-auth/src/last-admin-guard.ts | 87 ++++++- .../src/last-admin-standing-keys.test.ts | 6 +- ...tform-admin-standing.consolidation.test.ts | 19 +- .../src/session-grants-resolved-once.test.ts | 9 +- .../src/__tests__/security-catalog.testkit.ts | 19 +- .../src/explain-engine.test.ts | 9 +- .../src/position-catalog-refusal.test.ts | 9 +- .../src/position-name-fold-warning.test.ts | 5 +- .../src/security-plugin.test.ts | 7 + .../plugin-security/src/security-plugin.ts | 14 +- 25 files changed, 699 insertions(+), 404 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts diff --git a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index 950c4a81658..1c9ad3c081e 100644 --- a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -30,7 +30,7 @@ */ import type { ResolveUserAuthzGrantsOptions } from '../resolve-authz-context.js'; -import { bindCatalogFromTables } from './security-catalog.testkit.js'; +import { bindCatalogFromTables, ledgerFromTables } from './security-catalog.testkit.js'; // ── Recording ObjectQL double ─────────────────────────────────────────────── @@ -104,7 +104,10 @@ export function makeRecordingQl(tables: Record) { }); try { await new Promise((r) => setTimeout(r, 0)); - const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + // [ADR-0131 D3] A fixture's deactivated catalog rows reach the resolver as + // the activation-ledger rows the upgrade ceremony converts them to. + const stored = object === 'sys_metadata_activation' && !tables[object] ? ledgerFromTables(tables) : tables[object]; + const rows = (stored ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; } finally { inFlight -= 1; diff --git a/packages/core/src/security/__tests__/security-catalog.testkit.ts b/packages/core/src/security/__tests__/security-catalog.testkit.ts index c784275b434..4e9254810e4 100644 --- a/packages/core/src/security/__tests__/security-catalog.testkit.ts +++ b/packages/core/src/security/__tests__/security-catalog.testkit.ts @@ -14,7 +14,9 @@ * `sys_position_permission_set` rows become each position's `permissionSets` * through `convertPositionBindingRows` — the very conversion the upgrade * ceremony applies — so a fixture that resolved some grants from rows proves - * the converted definitions resolve the same ones. + * the converted definitions resolve the same ones. {@link ledgerFromTables} + * does the same for the rows' ADR-0049 `active` flags, through + * `convertDeactivatedCatalogRows`, into activation-ledger rows. */ import { @@ -22,7 +24,7 @@ import { createSecurityCatalogReader, type SecurityCatalogReader, } from '../security-catalog.js'; -import { convertPositionBindingRows } from '../position-binding-conversion.js'; +import { convertDeactivatedCatalogRows, convertPositionBindingRows } from '../position-binding-conversion.js'; type Definition = Record; @@ -106,3 +108,14 @@ export function catalogFromTables(tables: Record(engine: T, tables: Record): T { return bindStaticSecurityCatalog(engine, () => catalogFromTables(tables)); } + +/** + * The `sys_metadata_activation` rows a row-shaped fixture's `active: false` + * catalog rows convert to (`convertDeactivatedCatalogRows`, module doc). + */ +export function ledgerFromTables(tables: Record): Definition[] { + return convertDeactivatedCatalogRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: (tables.sys_permission_set ?? []) as Definition[], + }).ledger.map((row) => ({ ...row, package_id: null })); +} diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 7dde4a14896..c4459c44ca8 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -212,8 +212,8 @@ const VARIANTS: Record< }, // Standing taken away every way the resolver knows: the set switched off - // (ADR-0049), the grant scoped to an organization, the window closed - // (ADR-0091), the position deactivated, and the JSON blobs stored as strings. + // (ADR-0049, in the activation ledger), the grant scoped to an organization, + // the window closed (ADR-0091), and the JSON blobs stored as strings. 'standing revoked every way': { org: 'org_1', tables: { @@ -260,6 +260,11 @@ const VARIANTS: Record< }, { id: 'pst_2', name: 'contributor_set', active: true }, ], + // [ADR-0131 D3, ADR-0126 §4] The switch the resolver honours: the set row's + // own `active` above is no longer read. + sys_metadata_activation: [ + { id: 'act_1', metadata_type: 'permission', name: 'admin_full_access', package_id: null, active: false }, + ], }, }, diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index cf3cefbdeab..04717d4eb9d 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -98,38 +98,28 @@ export interface AdminStandingTable { * ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it * authenticates a principal and seeds `permissions` with the key's scopes, and * confers no administrator standing of its own — `hasPlatformAdminGrant` is set - * from a `sys_permission_set` row reached through an UNSCOPED + * from the catalog's `admin_full_access` set reached through an UNSCOPED * `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since * #11663 L5) or from the deployment config matched against the caller's own * STORED `sys_user` row (§6b-config, on every posture), never from a scope * string and never from the caller-seedable `grants.email`. */ export const ADMIN_STANDING_SURFACE: Readonly> = { - sys_permission_set: { + sys_metadata_activation: { role: 'derives', reason: - 'The row `admin_full_access` is resolved BY NAME from (§6b) — `platform_admin` is the ' - + "POSITION that row derives, not the row's own name. Renaming it, deleting it or switching " - + 'it off (ADR-0049 `active`, which this resolver honours) un-makes every GRANT-derived platform ' - + 'admin at once, with no identity table touched. ⚠️ It does NOT un-make a CONFIG-derived ' - + 'one (§6b-config): that route sets the same standing from ' - + "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own " - + 'stored `sys_user` row, so it touches an identity table and never reads this one. With ' - + '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, ' - + 'this row stops being the single point that un-makes every administrator. [ADR-0131 D4] ' - + "Its organization decides which row a grant's name resolves to — the grant's own " - + "organization's row, else the organization-less one — so an UNSCOPED grant reaches only " - + 'the organization-less `admin_full_access`: moving that row into an organization un-makes ' - + 'every grant-derived platform admin exactly as deleting it does. [ADR-0131 D3/D4] Since ' - + 'the catalog switch the row is read for its `active` flag alone: the set itself — its ' - + 'existence and its body — is the catalog definition (see the `security-catalog` non-table ' - + 'input), and a name with no row carries no flag, so deleting the row no longer un-makes ' - + 'anyone; switching it off still does.', + '[ADR-0131 D3, ADR-0126 §4] The activation ledger the resolver reads deactivation from ' + + '(`readDisabledCatalogNames`, §6a): a `permission` row of `admin_full_access` whose `active` ' + + 'is false un-makes every GRANT-derived platform administrator at once (§6b), with no identity ' + + 'table touched; a `position` row switches that position off. Deployment-wide (no tenant ' + + 'column). The `sys_permission_set` and `sys_position` rows are no longer read at all: the ' + + 'set itself — its existence and its body — is the catalog definition (see the ' + + '`security-catalog` non-table input). ⚠️ It does NOT un-make a CONFIG-derived administrator ' + + '(§6b-config), which never reads a set.', columns: [ + 'metadata_type', 'name', 'active', - 'organization_id', - 'organizationId', ], }, @@ -210,13 +200,6 @@ export const ADMIN_STANDING_SURFACE: Readonly + 'position-bound `admin_full_access` resolves the set name into `permissions` and leaves ' + '`hasPlatformAdminGrant` false.', }, - - sys_position: { - role: 'reads-only', - reason: - 'Read to drop DEACTIVATED positions (ADR-0049, §6a). Same reason as `sys_user_position`: ' - + 'the position path cannot reach `hasPlatformAdminGrant`.', - }, }; /** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */ diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index a5bb8e0cf98..0a2181eb0ca 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -251,6 +251,10 @@ export { // never at boot. export { convertPositionBindingRows, + convertDeactivatedCatalogRows, + type ActivatableCatalogRow, + type CatalogLedgerRow, + type CatalogDeactivationConversion, type CatalogRowForConversion, type PositionBindingRow, type PositionBindingConversionInput, diff --git a/packages/core/src/security/position-binding-conversion.test.ts b/packages/core/src/security/position-binding-conversion.test.ts index 7109a76fae7..94db5a29915 100644 --- a/packages/core/src/security/position-binding-conversion.test.ts +++ b/packages/core/src/security/position-binding-conversion.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest'; -import { convertPositionBindingRows } from './position-binding-conversion.js'; +import { convertDeactivatedCatalogRows, convertPositionBindingRows } from './position-binding-conversion.js'; const sets = [ { id: 'ps_read', name: 'read_all' }, @@ -114,3 +114,33 @@ describe('convertPositionBindingRows', () => { }); }); }); + +describe('convertDeactivatedCatalogRows', () => { + it('a name every row switches off becomes one ledger row; an active or flagless row converts to nothing', () => { + const out = convertDeactivatedCatalogRows({ + positions: [{ name: 'retired', active: false }, { name: 'auditor', active: true }, { name: 'contributor' }], + permissionSets: [{ name: 'dead_set', active: 0 }, { name: 'dead_set', active: false, organization_id: 'org_a' }], + }); + expect(out).toEqual({ + ledger: [ + { metadata_type: 'position', name: 'retired', active: false }, + { metadata_type: 'permission', name: 'dead_set', active: false }, + ], + conflicting: [], + }); + }); + + it('organizations that disagree about a name are conflicting — the deployment-wide ledger never guesses', () => { + const out = convertDeactivatedCatalogRows({ + positions: [], + permissionSets: [ + { name: 'tools', organization_id: 'org_a', active: false }, + { name: 'tools', organization_id: 'org_b', active: true }, + ], + }); + expect(out).toEqual({ + ledger: [], + conflicting: [{ metadata_type: 'permission', name: 'tools', byOrganization: { org_a: false, org_b: true } }], + }); + }); +}); diff --git a/packages/core/src/security/position-binding-conversion.ts b/packages/core/src/security/position-binding-conversion.ts index b200619a3bd..fc3f160e076 100644 --- a/packages/core/src/security/position-binding-conversion.ts +++ b/packages/core/src/security/position-binding-conversion.ts @@ -26,6 +26,10 @@ * * A binding whose position or set row is missing, or names nothing, is listed * in `dangling` and converted nowhere. + * + * Its sibling {@link convertDeactivatedCatalogRows} carries the rows' ADR-0049 + * `active` flags into the activation ledger, which is where the resolver reads + * deactivation from. */ /** One `sys_position` or `sys_permission_set` row, as the conversion reads it. */ @@ -142,3 +146,65 @@ export function convertPositionBindingRows(input: PositionBindingConversionInput } return { positions, dangling }; } + +/** One `sys_position` / `sys_permission_set` row as the deactivation conversion reads it. */ +export interface ActivatableCatalogRow extends CatalogRowForConversion { + readonly active?: unknown; +} + +/** + * An activation-ledger row (`sys_metadata_activation`, ADR-0126 §4) the + * conversion answers. The applier supplies `package_id`, which the ledger + * object requires and the catalog rows do not carry. + */ +export interface CatalogLedgerRow { + readonly metadata_type: 'position' | 'permission'; + readonly name: string; + readonly active: false; +} + +export interface CatalogDeactivationConversion { + /** The names to switch off in the ledger, one row each. */ + readonly ledger: readonly CatalogLedgerRow[]; + /** Names some organizations' rows switch off and others' keep on — reported, never guessed (D10 fate 4). */ + readonly conflicting: ReadonlyArray<{ metadata_type: 'position' | 'permission'; name: string; byOrganization: Readonly> }>; +} + +const rowIsOff = (value: unknown): boolean => + value === false || value === 0 || value === '0' || value === 'false'; + +/** + * [ADR-0131 D3, ADR-0126 §4] The catalog rows' ADR-0049 `active` flags, + * turned into activation-ledger rows. The resolver reads deactivation from the + * ledger, never from the row; the upgrade ceremony applies this with + * {@link convertPositionBindingRows}. ⛔ Never at boot. + * + * A name whose every row is off gets one ledger row. A name some + * organizations switched off and others did not is `conflicting`: the ledger + * is deployment-wide, so switching it off would revoke it from organizations + * that kept it. + */ +export function convertDeactivatedCatalogRows(input: { + readonly positions: readonly ActivatableCatalogRow[]; + readonly permissionSets: readonly ActivatableCatalogRow[]; +}): CatalogDeactivationConversion { + const ledger: CatalogLedgerRow[] = []; + const conflicting: Array<{ metadata_type: 'position' | 'permission'; name: string; byOrganization: Record }> = []; + for (const [metadataType, rows] of [['position', input.positions], ['permission', input.permissionSets]] as const) { + const byName = new Map>(); + for (const row of rows) { + const name = text(row.name); + if (!name) continue; + const states = byName.get(name) ?? {}; + states[organizationKey(row.organization_id)] = !rowIsOff(row.active); + byName.set(name, states); + } + for (const [name, states] of byName) { + const values = Object.values(states); + if (values.every((on) => on)) continue; + if (values.every((on) => !on)) ledger.push({ metadata_type: metadataType, name, active: false }); + else conflicting.push({ metadata_type: metadataType, name, byOrganization: states }); + } + } + return { ledger, conflicting }; +} diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json index 8bb204b6985..0b1b68539f3 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json @@ -40,15 +40,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true }, { @@ -115,8 +121,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_owner", @@ -124,7 +136,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true }, { @@ -188,15 +200,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true }, { @@ -272,48 +290,29 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", "contributor", "auditor", "retired", - "everyone" - ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "name": { - "$in": [ - "write_all", - "read_all", - "base_access" - ] - }, - "organization_id": null - }, - "limit": 500, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "name": { - "$in": [ + "everyone", "write_all", "read_all", + "admin_full_access", "base_access" ] } }, - "limit": 500, + "limit": 18, "isSystem": true }, { @@ -387,51 +386,33 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_member", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_user", - "where": { - "id": "u_ps" - }, - "limit": 1, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { + }, "name": { "$in": [ + "org_member", + "everyone", "admin_full_access", "org_tools", "dead_set" ] - }, - "organization_id": null + } }, - "limit": 500, + "limit": 10, "isSystem": true }, { - "object": "sys_permission_set", + "object": "sys_user", "where": { - "name": { - "$in": [ - "org_tools" - ] - } + "id": "u_ps" }, - "limit": 500, + "limit": 1, "isSystem": true } ] @@ -494,29 +475,24 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_admin", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { + }, "name": { "$in": [ + "org_admin", + "everyone", + "low", "organization_admin" ] - }, - "organization_id": null + } }, - "limit": 500, + "limit": 8, "isSystem": true }, { @@ -526,31 +502,6 @@ }, "limit": 1, "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "name": { - "$in": [ - "low" - ] - }, - "organization_id": null - }, - "limit": 500, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { - "name": { - "$in": [ - "organization_admin" - ] - } - }, - "limit": 500, - "isSystem": true } ] }, @@ -616,8 +567,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", @@ -625,7 +582,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true } ] @@ -679,15 +636,21 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "everyone" ] } }, - "limit": 200, + "limit": 2, "isSystem": true } ] @@ -749,29 +712,23 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { - "name": { + "metadata_type": { "$in": [ - "org_member", - "everyone" + "position", + "permission" ] - } - }, - "limit": 200, - "isSystem": true - }, - { - "object": "sys_permission_set", - "where": { + }, "name": { "$in": [ + "org_member", + "everyone", "extra" ] - }, - "organization_id": null + } }, - "limit": 500, + "limit": 6, "isSystem": true } ] @@ -2026,8 +1983,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_member", @@ -2035,7 +1998,7 @@ ] } }, - "limit": 200, + "limit": 4, "isSystem": true }, { @@ -2095,8 +2058,14 @@ "isSystem": true }, { - "object": "sys_position", + "object": "sys_metadata_activation", "where": { + "metadata_type": { + "$in": [ + "position", + "permission" + ] + }, "name": { "$in": [ "org_owner", @@ -2106,7 +2075,7 @@ ] } }, - "limit": 200, + "limit": 8, "isSystem": true }, { diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index 67aa715892b..ad03dc227ac 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -55,13 +55,20 @@ * upgrade ceremony's `convertPositionBindingRows`. In the two fixtures whose * positions bind sets (`position-derived-grants`, `tenant-admin-via-position`), * the `sys_position_permission_set` read and the `sys_permission_set` read by - * the junction's ids are gone; in their place the position-bound sets' rows are - * read BY NAME, for their ADR-0049 `active` flag only — the organization-less - * rows and the active organization's own rows, the pair a user grant's sets - * get. Same query count. Every `grants` envelope is byte-identical to the + * the junction's ids are gone. Every `grants` envelope is byte-identical to the * capture: the switch moved where a grant is read from, never what it grants. - * The by-name pair needs only the position NAMES, so those two fixtures resolve - * in three legs, not four. + * + * ⚠️ A third recorded move, in the same stage ([ADR-0049] as ADR-0131 D3 and + * ADR-0126 §4 place it): whether a position or a set is switched off is the + * activation ledger's answer, never the catalog row's `active` column. In + * every fixture the `sys_position` read, and every by-name `sys_permission_set` + * read, is replaced by ONE `sys_metadata_activation` read keyed by type + * (`position`, `permission`) and by every name the resolution may grant; its + * limit is two rows a name, the ledger being unique on (type, name). The + * recording double serves the ledger rows the fixture's `active: false` rows + * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's), so the + * deactivated `retired` position and `dead_set` set still grant nothing. Every + * fixture now resolves in two legs. */ import { readFileSync } from 'node:fs'; @@ -99,20 +106,18 @@ const GOLDEN: Record = JSON.parse( * control, written out per fixture rather than derived, so a regression that * re-serialises one read shows up as a number rather than as a slower suite. * - * Three is the floor, not two: wave 1 (every independent read) → wave 2 - * (`sys_position`, needs the position NAMES wave 1 produced) → wave 3 (the - * position-bound sets' `sys_permission_set` rows, by the names the held - * positions' catalog definitions name — [ADR-0131] D3/D4). The directly - * granted sets are read by NAME in wave 2, beside `sys_position` (D4), so a - * principal whose positions name no set skips wave 3 and lands in 2. + * Two is the floor: wave 1 (every independent read) → wave 2 (the activation + * ledger, which needs the position NAMES wave 1 produced, and the set names the + * grants and the positions' catalog definitions name — [ADR-0131] D3/D4). The + * definitions are read in-process, so no wave waits on a set row. */ const BATCHED_LEGS: Record = { 'empty-principal': 2, 'multi-org-membership': 2, 'lapsed-own-membership-among-active-peers': 2, - 'position-derived-grants': 3, + 'position-derived-grants': 2, 'permission-set-derived-grants': 2, - 'tenant-admin-via-position': 3, + 'tenant-admin-via-position': 2, 'ai-seat-and-email-from-sys-user': 2, 'ai-seat-denied': 2, 'seeded-permissions-and-email': 2, @@ -198,7 +203,7 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(ql.calls[0]!.where).toEqual({ name: { $in: ['org_member', 'everyone'] } }); }); - it('records the real `sys_position` read without the `platform_admin` unshifted in afterwards', async () => { + it('records the real position read (the activation ledger) without the `platform_admin` unshifted in afterwards', async () => { const f = FIXTURES.find((x) => x.name === 'permission-set-derived-grants')!; const ql = makeRecordingQl(f.tables); const grants = (await resolveUserAuthzGrants(ql, f.userId, f.opts)) as { positions: string[] }; @@ -207,9 +212,14 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(grants.positions[0]).toBe(BUILTIN_IDENTITY_PLATFORM_ADMIN); // The defect was recording that name into the QUERY, which was issued // before the unshift ran and therefore never carried it. - const positionReads = ql.calls.filter((c) => c.object === 'sys_position'); + // [ADR-0131 D3] The held positions are read by name from the activation + // ledger now, beside the sets the resolution may grant. + const positionReads = ql.calls.filter((c) => c.object === 'sys_metadata_activation'); expect(positionReads).toHaveLength(1); - expect(positionReads[0]!.where).toEqual({ name: { $in: ['org_member', 'everyone'] } }); + expect(positionReads[0]!.where).toEqual({ + metadata_type: { $in: ['position', 'permission'] }, + name: { $in: ['org_member', 'everyone', 'admin_full_access', 'org_tools', 'dead_set'] }, + }); expect(JSON.stringify(positionReads[0]!.where)).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); }); }); @@ -248,7 +258,7 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(wave1).toEqual( asMultiset(GOLDEN[name].queries) .map((s) => JSON.parse(s) as RecordedCall) - .filter((c) => c.object !== 'sys_position' && c.object !== 'sys_position_permission_set' && c.object !== 'sys_permission_set') + .filter((c) => c.object !== 'sys_metadata_activation') .map((c) => c.object) .sort(), ); diff --git a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts index e7385551aed..c9ffc353324 100644 --- a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -5,8 +5,9 @@ * permission set BY NAME — `sys_user_permission_set.permission_set` — in the * security catalog: the set exists, and grants what it grants, because the * catalog holds a definition of that name. Its `permission_set_id` is not read. - * The catalog row is read for one thing, its ADR-0049 `active` flag: the - * grant's own organization's row, else the organization-less row. + * No catalog row is read at all: whether a set or a position is switched off + * (ADR-0049) is the activation ledger's answer (`sys_metadata_activation`, + * ADR-0126 §4), deployment-wide. * * What is pinned here, on the recording double the batch-equivalence suite * drives (with an explicit catalog bound over it): @@ -15,8 +16,8 @@ * the name — and a name only a row carries (no definition) confers nothing; * - a grant that names nothing confers nothing, platform standing included; * - platform standing stands on an organization-less grant only; - * - the `active` flag: an organization's own row wins over the - * organization-less one, and a deactivated own row does not fall through; + * - deactivation: a ledger row switches a name off in every organization, and + * a catalog row's `active` column is not read; * - position-bound sets are the ones the position's definition names, and the * junction rows are not read at all. */ @@ -56,8 +57,8 @@ const catalog = () => [ { id: 'ps_tools_b', name: 'tools', organization_id: ORG_B }, { id: 'ps_tools', name: 'tools', organization_id: null }, { id: 'ps_row_only', name: 'row_only', organization_id: ORG_B }, - { id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false }, - { id: 'ps_on', name: 'switched', organization_id: null, active: true }, + { id: 'ps_off_a', name: 'switched', organization_id: ORG_A }, + { id: 'ps_on', name: 'switched', organization_id: null }, { id: 'ps_bound', name: 'bound_by_position', organization_id: null }, ]; @@ -76,6 +77,10 @@ const definitions = { /** The same catalog, with the `everyone` anchor distributing one set. */ const withEveryone = { ...definitions, positions: [{ name: 'everyone', permissionSets: ['bound_by_position'] }] }; +/** One activation-ledger row switching `name` off. */ +const off = (metadata_type: 'position' | 'permission', name: string) => + ({ metadata_type, name, package_id: null, active: false }); + function tables(grants: Array>, extra: Record = {}) { return { sys_user: [{ id: 'u1', email: 'u1@example.com' }], @@ -85,6 +90,8 @@ function tables(grants: Array>, extra: Record ({ user_id: 'u1', ...g })), sys_permission_set: catalog(), + // Stated, never derived from the rows: the ledger is the only switch. + sys_metadata_activation: [], ...extra, }; } @@ -128,13 +135,12 @@ describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set expect(out.posture).not.toBe('PLATFORM_ADMIN'); }); - it('the junction and the id bridge are never read; the catalog rows are read by NAME only', async () => { + it('the junction, the id bridge and the catalog rows are never read', async () => { const { ql } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: ORG_A }], ORG_A); - expect(ql.calls.map((c) => c.object)).not.toContain('sys_position_permission_set'); - for (const call of ql.calls.filter((c) => c.object === 'sys_permission_set')) { - expect(Object.keys(call.where as object)).not.toContain('id'); - expect(Object.keys(call.where as object)).toContain('name'); - } + const objects = ql.calls.map((c) => c.object); + expect(objects).not.toContain('sys_position_permission_set'); + expect(objects).not.toContain('sys_permission_set'); + expect(objects).not.toContain('sys_position'); }); }); @@ -162,29 +168,44 @@ describe('[ADR-0068 D2] platform standing stands on an organization-less grant o expect(out.permissions).toEqual([]); }); - it('a DEACTIVATED organization-less row takes the standing away', async () => { + it('admin_full_access switched off in the activation ledger takes the standing away', async () => { const { out } = await resolve( [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], undefined, - { sys_permission_set: [{ id: 'ps_admin', name: ADMIN_FULL_ACCESS, organization_id: null, active: false }] }, + { sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, ); expect(out.posture).not.toBe('PLATFORM_ADMIN'); expect(out.permissions).toEqual([]); }); }); -describe('[ADR-0049] the row that carries the `active` flag', () => { - it('a deactivated own row is the answer: it confers nothing and does not fall through to the organization-less row', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], ORG_A); - expect(out.permissions).not.toContain('switched'); - expect(out.systemPermissions).toEqual([]); +describe('[ADR-0049, ADR-0131 D3] the activation ledger switches a set off', () => { + it('a ledger row switches the name off in every organization: no set, no capability', async () => { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [null, undefined]] as const) { + const { out } = await resolve( + [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: grantOrg }], + tenantId, + { sys_metadata_activation: [off('permission', 'switched')] }, + ); + expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); + expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual([]); + } }); - it('CONTROL — an organization-less grant of the same name reads the organization-less row, which is active', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_on', permission_set: 'switched', organization_id: null }], ORG_A); + it('CONTROL — with no ledger row the same grant resolves', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], ORG_A); expect(out.permissions).toEqual(['switched']); expect(out.systemPermissions).toEqual(['cap_switched']); }); + + it('a catalog row\'s `active: false` is not read — no row fallback', async () => { + const { out } = await resolve( + [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: ORG_A }], + ORG_A, + { sys_permission_set: [{ id: 'ps_off_a', name: 'switched', organization_id: ORG_A, active: false }] }, + ); + expect(out.permissions).toEqual(['switched']); + }); }); describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { @@ -216,9 +237,9 @@ describe('[ADR-0131 D3/D4] position-bound sets are the ones the position definit expect(ql.calls.map((c) => c.object)).not.toContain('sys_position_permission_set'); }); - it('a DEACTIVATED position row drops the sets its definition names', async () => { + it('a position switched off in the activation ledger drops the sets its definition names', async () => { const { out } = await resolve([], ORG_A, { - sys_position: [{ id: 'p_everyone', name: 'everyone', organization_id: ORG_A, active: false }], + sys_metadata_activation: [off('position', 'everyone')], }, withEveryone); expect(out.permissions).toEqual([]); expect(out.positions).not.toContain('everyone'); diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts index aa9f44f5d7f..f82e86d7d49 100644 --- a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -418,10 +418,10 @@ describe('request-scoped grants memo — the round-trip count', () => { const standaloneQl = makeQl(makeTables()); await resolveUserAuthzGrants(standaloneQl, 'u_member', { tenantId: 'org_a', seedEmail: 'member@x.com', nowMs: T0 }); const oneResolution = standaloneQl.calls.length; - // [ADR-0131 D4] Ten: the member's organization grant reads its set by name, - // the organization's own row and the organization-less one, where the id - // read was one. - expect(oneResolution).toBe(10); + // [ADR-0131 D3/D4] Six: the sets and the positions are read from the + // catalog in-process, and ONE activation-ledger read answers which of them + // are switched off — no `sys_position` or `sys_permission_set` read. + expect(oneResolution).toBe(6); const memoQl = await armedQl(makeTables()); const withMemo = await resolveAuthzContext({ ql: memoQl, headers: {}, getSession: sessionReadThatResolves(memoQl, member), nowMs: T0 }); @@ -447,10 +447,10 @@ describe('request-scoped grants memo — the round-trip count', () => { expect(ql.middlewareCount()).toBe(0); const first = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); expect(ql.middlewareCount()).toBe(1); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const before = ql.calls.length; const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0 }); - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); expect(ql.middlewareCount()).toBe(1); expect(second).toEqual(first); }); @@ -488,9 +488,8 @@ describe('request-scoped grants memo — isolation', () => { expect(memberCtx.positions).toContain('org_owner'); // the member OWNS org_b expect(ownerCtx.org_user_ids).not.toContain('u_outsider'); expect(memberCtx.org_user_ids).toEqual(expect.arrayContaining(['u_member', 'u_outsider'])); - // Two requests, one resolution each — neither holds a user grant that - // applies where it operates, so neither reads a set by name. - expect(ql.calls.length).toBe(16); + // Two requests, one resolution each, six reads apiece. + expect(ql.calls.length).toBe(12); }); it('nothing outlives the request: the next request reads the grants as they are now', async () => { @@ -508,7 +507,7 @@ describe('request-scoped grants memo — isolation', () => { const second = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); expect(second.positions).not.toContain('auditor'); expect(second.permissions).not.toContain('read_all'); - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); }); it('a continuation the request started reads afresh once the request settled', async () => { @@ -533,7 +532,7 @@ describe('request-scoped grants memo — isolation', () => { const before = ql.calls.length; settled.resolve(); await late; - expect(ql.calls.length - before).toBe(10); + expect(ql.calls.length - before).toBe(6); }); it('a session read resolved against a SECOND engine serves nothing to the first engine\'s step 2', async () => { @@ -547,8 +546,8 @@ describe('request-scoped grants memo — isolation', () => { nowMs: T0, tenancyPosture: 'isolated', }); - expect(qlB.calls.length).toBe(10); - expect(qlA.calls.length).toBe(10); + expect(qlB.calls.length).toBe(6); + expect(qlA.calls.length).toBe(6); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -561,7 +560,7 @@ describe('request-scoped grants memo — isolation', () => { headers: {}, getSession: async () => { // The inner call resolves twice-over-once in ITS OWN scope: its session - // read's resolution serves its step 2 (10 reads), and that scope closes. + // read's resolution serves its step 2 (6 reads), and that scope closes. const inner = await resolveAuthzContext({ ql, headers: {}, @@ -576,7 +575,7 @@ describe('request-scoped grants memo — isolation', () => { tenancyPosture: 'isolated', }); // Inner 8, outer step 2 afresh 8. - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); }); @@ -620,7 +619,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re expect(ctx.positions).not.toContain('auditor'); expect(ctx).toEqual(await postWriteBaseline()); // …because step 2 read afresh after the landing. - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a write that bumped before the first resolution and is still in flight at step 2', async () => { @@ -632,7 +631,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tables.sys_user_position = withoutMembersAuditor(tables.sys_user_position); }); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); // Not landed yet: a fresh read still sees the row, and so did step 2. expect(ctx.positions).toContain('auditor'); landing.resolve(); @@ -657,7 +656,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tenancyPosture: 'isolated', }); expect(ctx.positions).not.toContain('auditor'); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); expect(ctx).toEqual(await postWriteBaseline()); }); @@ -675,7 +674,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re nowMs: T0, tenancyPosture: 'isolated', }); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a write that starts while the first resolution is still reading', async () => { @@ -684,19 +683,19 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re let started = false; let w: Promise | undefined; ql.find = async (object: string, opts: any) => { - if (!started && object === 'sys_position') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } + if (!started && object === 'sys_metadata_activation') { started = true; w = ql.write('sys_audit_log', 'insert', async () => {}); } return find(object, opts); }; await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member), nowMs: T0, tenancyPosture: 'isolated' }); await w; - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a step-2 clock later than the first resolution\'s, inside its validity window, is served', async () => { // temp_role is valid until T0 + 1 day: T0 + 1 hour is inside the window. const ql = await armedQl(makeTables()); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(10); + expect(ql.calls.length).toBe(6); expect(ctx.positions).toContain('temp_role'); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + HOUR, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); @@ -709,14 +708,14 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx.positions).not.toContain('temp_role'); expect(ctx.permissions).not.toContain('temp_tools'); - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); const baseline = await resolveAuthzContext({ ql: makeQl(makeTables()), headers: {}, getSession: sessionReadOnly(member), nowMs: T0 + 2 * DAY, tenancyPosture: 'isolated' }); expect(ctx).toEqual(baseline); // A step-2 clock EARLIER than the resolution it would be served. const ql2 = await armedQl(makeTables()); await resolveAuthzContext({ ql: ql2, headers: {}, getSession: sessionReadThatResolves(ql2, member, T0), nowMs: T0 - DAY, tenancyPosture: 'isolated' }); - expect(ql2.calls.length).toBe(20); + expect(ql2.calls.length).toBe(12); }); it('a bypassGrantsCache caller is never served from the memo', async () => { @@ -734,7 +733,7 @@ describe('request-scoped grants memo — step 2 reads afresh whenever a fresh re tenancyPosture: 'isolated', }); // session read 8 + the bypass caller's own 8 + step 2 served (0). - expect(ql.calls.length).toBe(20); + expect(ql.calls.length).toBe(12); }); it('a first resolution that failed is not remembered', async () => { @@ -759,7 +758,7 @@ describe('request-scoped grants memo — served values are clones', () => { const sink: { payload?: any; grants?: UserAuthzGrants } = {}; const member = sessionOf('u_member', 'member@x.com', 'org_a'); const ctx = await resolveAuthzContext({ ql, headers: {}, getSession: sessionReadThatResolves(ql, member, T0, sink), nowMs: T0, tenancyPosture: 'isolated' }); - expect(ql.calls.length).toBe(10); + expect(ql.calls.length).toBe(6); expect(ctx.positions).toEqual(sink.payload.user.positions); expect(ctx.positions).not.toBe(sink.payload.user.positions); ctx.positions.push('mutated_downstream'); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index a056c2cd4ce..f61ef17cc3a 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1333,20 +1333,27 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => { * over-revoke a set the user also holds in their own right. * * The predicate is "explicitly deactivated", not "explicitly active": absent - * means ACTIVE, so a row that predates the column keeps working. Every fixture - * above this block carries no `active` key at all and is the pin for that + * means ACTIVE, so a name with no ledger row keeps working. Every fixture + * above this block carries no ledger row at all and is the pin for that * direction — requiring `true` would have turned this file red wholesale, which * is what it would do to deployed data. + * + * [ADR-0131 D3, ADR-0126 §4] The switch is the activation ledger + * (`sys_metadata_activation`, types `position` / `permission`), deployment-wide. + * The catalog ROW's `active` column is no longer read: the last case pins it. */ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { + const off = (metadata_type: 'position' | 'permission', name: string, active: unknown = false) => + ({ metadata_type, name, package_id: null, active }); const withActive = (v: unknown) => ({ sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], - sys_position: [{ id: 'r1', name: 'contributor', active: v }], + sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], + sys_metadata_activation: v === undefined ? [] : [off('position', 'contributor', v)], }); // ── sys_position ────────────────────────────────────────────────────────── @@ -1386,7 +1393,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(ctx.systemPermissions).toContain('cap_x'); }); - it('an ABSENT `active` column grants — deployed rows are not mass-revoked', async () => { + it('an ABSENT ledger row grants — deployed positions are not mass-revoked', async () => { const ctx = await resolveAuthzContext({ ql: makeQl(withActive(undefined)), headers: H(), @@ -1411,15 +1418,15 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(on.permissions).toContain('contributor_ps'); }); - it('a position name with NO `sys_position` row is untouched (org roles, memberships)', async () => { + it('a position name with NO ledger row is untouched (org roles, memberships)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }], sys_user_position: [], sys_user_permission_set: [], - // `org_owner` is projected from the membership and has no catalogue row — + // `org_owner` is projected from the membership and has no ledger row — // there is no flag to read, so nothing may be inferred from its absence. - sys_position: [{ id: 'r9', name: 'something_else', active: false }], + sys_metadata_activation: [off('position', 'something_else')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('org_owner'); @@ -1435,9 +1442,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_user_permission_set: [], sys_position: [ - { id: 'r1', name: 'contributor', active: false }, - { id: 'r2', name: 'reviewer', active: true }, + { id: 'r1', name: 'contributor' }, + { id: 'r2', name: 'reviewer' }, ], + sys_metadata_activation: [off('position', 'contributor'), off('position', 'reviewer', true)], sys_position_permission_set: [ { position_id: 'r1', permission_set_id: 'ps1' }, { position_id: 'r2', permission_set_id: 'ps2' }, @@ -1465,10 +1473,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'crm_full', - active: false, system_permissions: ['cap_x'], tab_permissions: { crm: 'visible' }, }], + sys_metadata_activation: [off('permission', 'crm_full')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); @@ -1485,9 +1493,9 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'psA', name: 'admin_full_access', - active: false, system_permissions: ['manage_users'], }], + sys_metadata_activation: [off('permission', 'admin_full_access')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); // Dropped BEFORE the derivation, so the posture cannot be read off a set @@ -1509,9 +1517,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: null }, ], sys_permission_set: [ - { id: 'ps1', name: 'crm_full', active: false }, - { id: 'ps2', name: 'crm_read', active: true }, + { id: 'ps1', name: 'crm_full' }, + { id: 'ps2', name: 'crm_read' }, ], + sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).not.toContain('crm_full'); @@ -1526,9 +1535,10 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_member: [], sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }], - sys_position: [{ id: 'r1', name: 'contributor', active: false }], + sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], + sys_metadata_activation: [off('position', 'contributor')], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); expect(ctx.permissions).toContain('crm_full'); @@ -1540,6 +1550,46 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { expect(grants.permissions).not.toContain('contributor_ps'); expect(grants.positions).not.toContain('contributor'); }); + + it('[ADR-0131 D3] a composition whose registry has no ledger object issues no ledger read and resolves', async () => { + // The engine of such a composition answers "object not found" — a failed + // read, not a missing table — so asking it would be a 503 on every request. + const reads: string[] = []; + const tables = withActive(false); + const base = makeQl(tables); + const find = base.find.bind(base); + const ql = Object.assign(base, { + registry: { getObject: (name: string) => (name === 'sys_metadata_activation' ? undefined : { name }) }, + find: async (object: string, opts: any) => { + reads.push(object); + if (object === 'sys_metadata_activation') throw new Error("Object 'sys_metadata_activation' not found"); + return find(object, opts); + }, + }); + const grants = await resolveUserAuthzGrants(ql, 'u1'); + expect(reads).not.toContain('sys_metadata_activation'); + // No ledger in the composition: nothing is switched off. + expect(grants.permissions).toContain('contributor_ps'); + // CONTROL — the same engine with the ledger registered reads it, and honours it. + const registered = Object.assign(makeQl(tables), { registry: { getObject: (name: string) => ({ name }) } }); + expect((await resolveUserAuthzGrants(registered, 'u1')).permissions).not.toContain('contributor_ps'); + }); + + it('[ADR-0131 D3] a catalog ROW\'s `active: false` is not read — the ledger is the switch', async () => { + // The rows' flags are carried into the ledger by the upgrade ceremony + // (`convertDeactivatedCatalogRows`); the resolver never falls back to them. + const ctx = await resolveAuthzContext({ + ql: makeQl({ + ...withActive(undefined), + sys_position: [{ id: 'r1', name: 'contributor', active: false }], + sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', active: false, system_permissions: ['cap_x'] }], + }), + headers: H(), + getSession: session('u1'), + }); + expect(ctx.positions).toContain('contributor'); + expect(ctx.permissions).toContain('contributor_ps'); + }); }); /** diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index a121c2ce154..cc8039bc6f2 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -69,8 +69,8 @@ import { openUserGrantsCache } from './resolve-user-grants-cache.js'; import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-memo.js'; import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; import { derivePosture } from './posture-ladder.js'; -import { isRowActive } from './row-active.js'; import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js'; +import { METADATA_ACTIVATION_TABLE } from '../utils/metadata-activation-store.js'; /** The transport-agnostic authorization envelope produced from a request. */ export interface ResolvedAuthzContext { @@ -702,9 +702,6 @@ function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefi return !organizationId || organizationId === tenantId; } -/** The catalog a user grant (§6) names its permission set in. */ -const PERMISSION_SET_CATALOG = 'sys_permission_set'; - /** * [ADR-0131 D4] The permission set a stored `sys_user_permission_set` grant * names: its `permission_set`, or `undefined` for a grant that names none — @@ -738,63 +735,64 @@ function grantOrganizationOf(row: unknown): string | null { return String(value); } +/** The activation ledger's catalog types the resolver honours (ADR-0126 §3 regime C, as ADR-0131 D6 amends it). */ +const LEDGER_CATALOG_TYPES = ['position', 'permission'] as const; + +/** Whether the engine's registry says the activation ledger is absent from this composition. */ +function ledgerUnregistered(ql: any): boolean { + const getObject = ql?.registry?.getObject; + if (typeof getObject !== 'function') return false; + try { + return !getObject.call(ql.registry, METADATA_ACTIVATION_TABLE); + } catch { + return false; // a registry that cannot answer is not an answer: read, and let the read decide + } +} + /** - * [ADR-0049] The `sys_permission_set` rows that carry the `active` flag of the - * sets `names` names, found BY NAME: the active organization's own row first, - * else the organization-less row. The row is read for its flag and for nothing - * else — the set's body is its catalog definition ({@link catalogSetBody}), - * and no row decides whether a set exists (ADR-0131 D3/D4). - * - * The catalog rows are still materialized per organization, so one name can - * carry several rows. A row of an organization applies to that organization - * only, an organization-less row everywhere — the grant rule - * {@link grantAppliesInTenant} asks of a grant, asked of the set row. + * [ADR-0049, ADR-0131 D3, ADR-0126 §4] Which of `positions` and `sets` are + * switched OFF for this deployment: the `sys_metadata_activation` rows of type + * `position` / `permission` whose `active` is false. ADR-0131 moves the + * active/inactive switch off the catalog row "into the environment-authored + * definition's lifecycle, since there is no row", and regime C's "disable is + * one `active` bit of deployment-level state in the tenant-less activation + * ledger". So the catalog ROW's `active` column is not read here at all. * - * Two reads, issued together: the organization-less rows for every name in - * `names`, and — for the names in `ownNames`, when there is an active - * organization — that organization's rows, with the organization threaded into - * the context so the driver's tenant scope (`applyTenantScope`, the one - * governed spelling of the wall) decides what is visible. + * The ledger is deployment-level (no tenant column): a disabled set is + * disabled in every organization. Absence of a row means ACTIVE, and a driver + * `0` reads as false — the store's own reading + * (`ObjectStoreMetadataActivationStore`, `utils/metadata-activation-store.ts`). + * One read, keyed by type and name, so it is bounded by the names asked. * - * Which row answers is decided before its flag is read: an organization's own - * row that is deactivated is that organization's answer, and it does not fall - * through to the organization-less row. - * - * A read that fails throws (`tryFind`'s `AuthzStoreUnavailableError`). + * A read that fails throws (`tryFind`'s `AuthzStoreUnavailableError`); a ledger + * table that was never provisioned reads as no row, as any authz table does. + * A composition that does not register the ledger object at all (no + * `PlatformObjectsPlugin`, its one registrant) holds no row either, and its + * engine answers "object not found" rather than a missing table — so the + * registry is asked first, as `ObjectQLPlugin`'s own ledger hydration does, + * and the read is not issued. An engine with no registry to ask is read. */ -async function readSetActivationRows( +async function readDisabledCatalogNames( ql: any, - names: ReadonlySet, - ownNames: ReadonlySet, - tenantId: string | undefined, -): Promise<{ organizationLess: Map; own: Map }> { - const organizationLess = new Map(); - const own = new Map(); - if (names.size === 0) return { organizationLess, own }; - const [organizationLessRows, ownRows] = await Promise.all([ - tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(names) }, organization_id: null }, 500), - tenantId && ownNames.size > 0 - ? tryFind(ql, PERMISSION_SET_CATALOG, { name: { $in: Array.from(ownNames) } }, 500, tenantId) - : Promise.resolve([] as any[]), - ]); - for (const row of organizationLessRows) { - if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== null) continue; - if (!organizationLess.has(row.name)) organizationLess.set(row.name, row); - } - for (const row of ownRows) { - if (typeof row?.name !== 'string' || grantOrganizationOf(row) !== tenantId) continue; - if (!own.has(row.name)) own.set(row.name, row); + positions: readonly string[], + sets: readonly string[], +): Promise<{ positions: Set; permissions: Set }> { + const disabled = { positions: new Set(), permissions: new Set() }; + const names = [...new Set([...positions, ...sets])]; + if (names.length === 0) return disabled; + if (ledgerUnregistered(ql)) return disabled; + const rows = await tryFind( + ql, + METADATA_ACTIVATION_TABLE, + { metadata_type: { $in: [...LEDGER_CATALOG_TYPES] }, name: { $in: names } }, + names.length * LEDGER_CATALOG_TYPES.length, + ); + for (const row of rows) { + if (typeof row?.name !== 'string' || !(row.active === false || row.active === 0)) continue; + if (row.metadata_type === 'position') disabled.positions.add(row.name); + else if (row.metadata_type === 'permission') disabled.permissions.add(row.name); } - return { organizationLess, own }; -} - -/** - * A set is in effect unless the row that answers for it says otherwise: no row - * carries no flag to read, the way a position name with no `sys_position` row - * is untouched at §6a. Existence is the catalog's question, never the row's. - */ -function activationAllows(row: unknown): boolean { - return row === undefined || isRowActive(row as any); + return disabled; } /** @@ -1061,29 +1059,21 @@ export async function resolveUserAuthzGrants( // what it grants. The grant's `permission_set_id` is not read: a grant // that names nothing, or a name the catalog does not hold, confers nothing. // - // [ADR-0049] The `active` flag is the one thing still read from a row: the - // grant's own organization's row, else the organization-less one - // ({@link readSetActivationRows}). Issued now, so it shares the - // `sys_position` read's leg below; awaited at §6b. + // [ADR-0049] Whether a set is switched off is the activation ledger's + // answer ({@link readDisabledCatalogNames}), read in §6a with the + // positions' — never a catalog row's `active` column. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); const applyingGrants = upsRows.filter((r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId)); const catalog = securityCatalogReaderOf(ql); - const grantSetNames = new Set(); - const ownGrantSetNames = new Set(); + const grantSetNames: string[] = []; for (const grant of applyingGrants) { const name = grantSetNameOf(grant); - if (!name) continue; - grantSetNames.add(name); - const organizationId = grantOrganizationOf(grant); - if (organizationId !== null && organizationId === tenantId) ownGrantSetNames.add(name); + if (name && !grantSetNames.includes(name)) grantSetNames.push(name); } - const grantActivationLeg = readSetActivationRows(ql, grantSetNames, ownGrantSetNames, tenantId); - // Its failure is observed where it is awaited (§6b); this only keeps a throw - // from §6a, which runs first, from leaving the rejection unhandled. - grantActivationLeg.catch(() => undefined); // Position-bound sets (§6a), by name, in the order the held positions name them. const boundSetNames: string[] = []; + let disabled = { positions: new Set(), permissions: new Set() }; // [#11663 L5] …and under a WALLED posture it no longer is. This is the EXIT of // the migration window L4 opened: the walled half of the legacy unscoped anchor // is RETIRED, so on a walled rig platform standing is config-derived @@ -1133,58 +1123,29 @@ export async function resolveUserAuthzGrants( // read: `resolvePermissionSetsForContext` requests `positions` as // permission-set NAMES (position names are commonly reused as set names), // so a name left standing would resolve the same grant one layer down. - // Only a name whose row is explicitly deactivated is dropped — a name - // with no `sys_position` row at all (`org_owner`, a membership-derived - // role) has no flag to read and is untouched. + // Only a name the activation ledger switched off is dropped + // ({@link readDisabledCatalogNames}); a name with no ledger row is in + // effect, a membership-derived role (`org_owner`) included. if (grants.positions.length > 0) { - // The `sys_position` row is read for its ADR-0049 `active` flag and for - // nothing else. - // - // [#10103] Scoped to the CALLER's organization. `sys_position` spells - // its name index `unique: 'organization'` and its rows are materialized - // per organization, so several organizations hold a row named - // `everyone` (and one named after every declared position). Swept by - // name alone, this read returned EVERY organization's rows — another - // organization's deactivation then decided this one's grant. It also - // made the sweep O(organizations) on a table that is read on every - // request. - // - // Scoped by threading the organization into the context rather than by - // adding an `organization_id` predicate here: the driver's - // `applyTenantScope` is the one governed spelling of this wall, and a - // bare equality written at this call site would be a second, ungoverned - // implementation of it — the exact shape that produced the defect this - // card repairs. Per-request cost stays O(the caller's own organization's - // catalog). - // - // Limit raised with it: the cap has to admit this organization's rows - // alongside any organization-less ones the driver's compatibility arm - // still returns, or a caller silently loses a deactivation. - // - // [#20515] The same grant rule as §4 and §6 is then asked of each - // position row ({@link grantAppliesInTenant}). With a tenant it is a - // no-op — the driver's scope already returned only this organization's - // rows and the organization-less ones. With NO tenant the read above is - // installation-wide by design, so without it every organization's - // `everyone` row, and every organization's copy of a name the caller - // holds (the `sys_member` role projection's `org_member`, say), fed its - // row into an organization-less resolution. This is the grant rule, not - // a second tenant wall: it decides which organization's rows APPLY, - // after the driver decided which rows are visible. - const positionRows = (await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId)) - .filter((r) => grantAppliesInTenant(r.organization_id, tenantId)); - const deactivatedNames = new Set( - positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean), - ); - if (deactivatedNames.size > 0) { - grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n)); - } + // The definitions first (in-process), so the ONE ledger read can ask about + // every position and every set this resolution may grant. + const namedByPosition = new Map(); if (catalog) { for (const positionName of grants.positions) { - const entry = await catalog.resolve('position', positionName); - for (const setName of positionSetNamesOf(entry?.definition)) { - if (!boundSetNames.includes(setName)) boundSetNames.push(setName); - } + namedByPosition.set(positionName, positionSetNamesOf((await catalog.resolve('position', positionName))?.definition)); + } + } + disabled = await readDisabledCatalogNames( + ql, + grants.positions, + [...grantSetNames, ...[...namedByPosition.values()].flat()], + ); + if (disabled.positions.size > 0) { + grants.positions = grants.positions.filter((n) => !disabled.positions.has(n)); + } + for (const positionName of grants.positions) { + for (const setName of namedByPosition.get(positionName) ?? []) { + if (!boundSetNames.includes(setName)) boundSetNames.push(setName); } } } @@ -1194,13 +1155,8 @@ export async function resolveUserAuthzGrants( // // The user grants' sets (§6) come first, in grant order; the // position-bound sets (§6a) follow, in the order the positions name them. - // Each body is its catalog definition; each `active` flag is its row's — - // the active organization's own row first, else the organization-less - // one, for a position-bound set as for an organization's grant. - const [grantActivation, boundActivation] = await Promise.all([ - grantActivationLeg, - readSetActivationRows(ql, new Set(boundSetNames), tenantId ? new Set(boundSetNames) : new Set(), tenantId), - ]); + // Each body is its catalog definition; whether it is switched off is the + // activation ledger's answer (§6a). const psRowsAll: Array = []; const takenNames = new Set(); const take = (body: CatalogSetBody, active: boolean): void => { @@ -1214,13 +1170,12 @@ export async function resolveUserAuthzGrants( const body = await catalogSetBody(catalog, name); if (!body) continue; const ownGrant = grantOrganizationOf(grant) !== null; - const row = (ownGrant ? grantActivation.own.get(name) : undefined) ?? grantActivation.organizationLess.get(name); - const active = activationAllows(row); + const active = !disabled.permissions.has(name); take(body, active); // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access - // USER grant — the single source of truth (no trusted stored boolean). An - // unscoped grant reads the organization-less row only, so the set it holds - // is the platform's own `admin_full_access`, never an organization's copy. + // USER grant — the single source of truth (no trusted stored boolean). The + // set is the catalog's one `admin_full_access` (ADR-0131 D4); an + // organization's grant of it confers the set, never platform standing. // // [ADR-0049] A DEACTIVATED set confers no standing either: a deactivated // `admin_full_access` must not keep conferring PLATFORM_ADMIN. @@ -1242,7 +1197,7 @@ export async function resolveUserAuthzGrants( for (const name of boundSetNames) { const body = await catalogSetBody(catalog, name); if (!body) continue; - take(body, activationAllows(boundActivation.own.get(name) ?? boundActivation.organizationLess.get(name))); + take(body, !disabled.permissions.has(name)); } if (psRowsAll.length > 0) { // [ADR-0049] A DEACTIVATED permission set grants nothing — the diff --git a/packages/core/src/security/resolve-user-grants-cache.ts b/packages/core/src/security/resolve-user-grants-cache.ts index 8a3875a7b43..aead9a48791 100644 --- a/packages/core/src/security/resolve-user-grants-cache.ts +++ b/packages/core/src/security/resolve-user-grants-cache.ts @@ -122,6 +122,9 @@ export const GRANTS_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set([ 'sys_position_permission_set', 'sys_permission_set', 'sys_user', + // [ADR-0131 D3, ADR-0126 §4] The activation ledger: a position or permission + // set switched off there stops granting (`readDisabledCatalogNames`). + 'sys_metadata_activation', ]); interface GrantsCacheEntry { 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 new file mode 100644 index 00000000000..16f2f58144b --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts @@ -0,0 +1,58 @@ +// 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. + */ + +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); + return rows.filter((r) => r.id === query?.where?.id); + }, + }; +}; + +describe('[ADR-0131 D3] refuseLedgerSwitchingAdminOff', () => { + it('refuses an insert switching admin_full_access off, the 0 spelling included', async () => { + for (const active of [false, 0]) { + await expect( + refuseLedgerSwitchingAdminOff(engineWith([]), { metadata_type: 'permission', name: 'admin_full_access', active }, undefined), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: 'sys_metadata_activation' }); + } + }); + + 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('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('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([]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index d0ab905c93d..d57030a02ed 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -555,6 +555,46 @@ 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'; @@ -780,6 +820,22 @@ 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. + * + * 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. + */ +export const ACTIVATION_LEDGER_STANDING_KEYS = ['metadata_type', 'name', 'active'] as const; + /** * [commit f8eb73601] The three lists above, keyed by the table each one judges — the shape * the correspondence gate consumes. @@ -800,7 +856,7 @@ export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; export const STANDING_KEYS_BY_TABLE: Readonly> = { [SystemObjectName.MEMBER]: MEMBER_STANDING_KEYS, [USER_PERMISSION_SET]: GRANT_STANDING_KEYS, - [SystemObjectName.PERMISSION_SET]: PERMISSION_SET_STANDING_KEYS, + [METADATA_ACTIVATION]: ACTIVATION_LEDGER_STANDING_KEYS, [SystemObjectName.USER]: USER_STANDING_KEYS, }; @@ -854,9 +910,7 @@ export const STANDING_KEY_EXCLUSIONS: Readonly => { + 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); + }; + const guardPermissionSetDelete = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); if (ctx.object !== SystemObjectName.PERMISSION_SET) return; @@ -1730,11 +1796,22 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeInsert', guardActivationLedgerWrite, { + object: METADATA_ACTIVATION, + priority: 20, + packageId, + }); + engine.registerHook('beforeUpdate', guardActivationLedgerWrite, { + object: METADATA_ACTIVATION, + priority: 20, + packageId, + }); 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) — ADR-0135 D5.2', + '(the admin_full_access row every platform admin is derived from), 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 34664d8bc21..68bdfacb179 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 @@ -51,7 +51,7 @@ import { ADMIN_STANDING_SURFACE, adminStandingTables } from '@objectstack/core'; import { GRANT_STANDING_KEYS, MEMBER_STANDING_KEYS, - PERMISSION_SET_STANDING_KEYS, + ACTIVATION_LEDGER_STANDING_KEYS, STANDING_KEYS_BY_TABLE, STANDING_KEY_EXCLUSIONS, } from './last-admin-guard.js'; @@ -127,6 +127,8 @@ describe('[#8734] standing-key lists correspond to what resolveAuthzContext read // drift into a second copy that is checked while the guard uses another. expect(STANDING_KEYS_BY_TABLE.sys_member).toBe(MEMBER_STANDING_KEYS); expect(STANDING_KEYS_BY_TABLE.sys_user_permission_set).toBe(GRANT_STANDING_KEYS); - expect(STANDING_KEYS_BY_TABLE.sys_permission_set).toBe(PERMISSION_SET_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). + expect(STANDING_KEYS_BY_TABLE.sys_metadata_activation).toBe(ACTIVATION_LEDGER_STANDING_KEYS); }); }); diff --git a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts index 274db7beb90..cbb90d7b27d 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts @@ -183,7 +183,10 @@ const impersonateVerdict = async (manager: AuthManager, bearer: string, userId: interface StandingShape { /** Rows written into `sys_permission_set` BEFORE `admin_full_access`. */ decoySets?: number; - /** ADR-0049: the `admin_full_access` catalogue row is switched off. */ + /** + * ADR-0049: `admin_full_access` is switched off — in the activation ledger, + * the switch the resolver honours (ADR-0131 D3, ADR-0126 §4). + */ deactivatedSet?: boolean; /** ADR-0091: the grant carries a `valid_until` already in the past. */ expiredGrant?: boolean; @@ -195,11 +198,15 @@ const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingSha for (let i = 0; i < (shape.decoySets ?? 0); i += 1) { await engine.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); } - await engine.insert('sys_permission_set', { - id: PS_ADMIN, - name: ADMIN_FULL_ACCESS, - ...(shape.deactivatedSet ? { active: false } : {}), - }); + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + if (shape.deactivatedSet) { + await engine.insert('sys_metadata_activation', { + metadata_type: 'permission', + name: ADMIN_FULL_ACCESS, + package_id: null, + active: false, + }); + } await engine.insert('sys_user_permission_set', { user_id: userId, permission_set_id: PS_ADMIN, diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index 78ee644fec9..94115d514c2 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -266,14 +266,15 @@ describe('the real customSession hook and resolveAuthzContext resolve the caller expect(grantOnlyReads(twice.reads).length).toBe(2 * grantOnlyReads(once.reads).length); }); - it('the request saves exactly one resolution\'s reads — eight for a caller in an organization', async () => { + it('the request saves exactly one resolution\'s reads — six for a caller in an organization', async () => { const a = await arrangeWarm(); const once = await resolveRequest(a, a.bearers.member); const twice = await resolveRequestWithoutMemo(a, a.bearers.member); // sys_user, sys_member (own), sys_user_position, sys_member (peers), - // sys_user_permission_set, sys_position, sys_position_permission_set, - // sys_permission_set. - expect(twice.reads.length - once.reads.length).toBe(8); + // sys_user_permission_set, sys_metadata_activation. [ADR-0131 D3/D4] The + // positions, their sets and the set bodies are read from the catalog + // in-process; the one ledger read answers which are switched off. + expect(twice.reads.length - once.reads.length).toBe(6); }); it('organization owner and platform operator: same envelope, one resolution', async () => { diff --git a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts index a4c173ebf20..74913a4b457 100644 --- a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -10,7 +10,12 @@ * conversion the upgrade ceremony applies. */ -import { bindSecurityCatalogReader, convertPositionBindingRows, createSecurityCatalogReader } from '@objectstack/core'; +import { + bindSecurityCatalogReader, + convertDeactivatedCatalogRows, + convertPositionBindingRows, + createSecurityCatalogReader, +} from '@objectstack/core'; type Definition = Record; @@ -70,3 +75,15 @@ export function catalogFromTables(tables: Record(engine: T, tables: Record): T { return bindTestSecurityCatalog(engine, () => catalogFromTables(tables)); } + +/** + * The `sys_metadata_activation` rows a fixture's `active: false` catalog rows + * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's): the + * resolver reads deactivation from the ledger, never from the row. + */ +export function ledgerFromTables(tables: Record): Definition[] { + return convertDeactivatedCatalogRows({ + positions: (tables.sys_position ?? []) as Definition[], + permissionSets: (tables.sys_permission_set ?? []) as Definition[], + }).ledger.map((row) => ({ ...row, package_id: null })); +} diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 942c47bf9db..743f5d43c80 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -9,7 +9,7 @@ import { explainAccess, buildContextForUser, resolveDelegatorContext, type Expla import { RLS_DENY_FILTER } from './rls-compiler'; import { unresolvedPostureRemedy } from './unresolved-posture'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; +import { bindCatalogFromTables, ledgerFromTables } from './__tests__/security-catalog.testkit.js'; // [commit a68c61267] `ExplainDecision.layers` is `ExplainLayer[]` — the z.INPUT shape // (ADR-0122), in which every `.default([])` member is OPTIONAL before a parse: @@ -873,7 +873,12 @@ function makeGrantQl(tables: Rows) { return bindCatalogFromTables({ async find(object: string, opts: any) { const where = opts?.where ?? {}; - return (tables[object] ?? []).filter((row) => + // [ADR-0131 D3] The resolver reads deactivation from the activation + // ledger: a fixture's `active: false` rows reach it as the ledger rows the + // upgrade ceremony converts them to. The explainer's own "deactivated" + // annotation still reads the row (stage 2 moves it). + const stored = object === 'sys_metadata_activation' && !tables[object] ? ledgerFromTables(tables) : tables[object]; + return (stored ?? []).filter((row) => Object.entries(where).every(([key, cond]) => { if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); const cell = row[key]; // `null` matches a null-valued AND a key-absent cell — the memory diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index b694ff7372e..a8681ab8a59 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -32,6 +32,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; @@ -120,7 +121,8 @@ async function boot(opts: { walled?: boolean } = {}) { scope: 'system', // `sys_member` is provisioned: an organization-scoped assignment must name // a member of its organization (`grant-holder-membership-refusal.ts`). - objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember, QA_INQUIRY], + // `sys_metadata_activation`: the ledger the resolver reads deactivation from (ADR-0131 D3). + objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember, SysMetadataActivation, QA_INQUIRY], } as any); await engine.syncSchemas(); // [#21516] The authz resolver reads these on every grant resolution; in a @@ -189,6 +191,11 @@ async function boot(opts: { walled?: boolean } = {}) { { id: 'pps_auditor', position_id: AUDITOR_POSITION_ID, permission_set_id: 'ps_auditor' }, { id: 'pps_retired', position_id: 'pos_retired', permission_set_id: 'ps_auditor' }, ], { context: seed } as any); + // [ADR-0131 D3, ADR-0126 §4] The switch the resolver honours: the row's own + // `active: false` above is no longer read. + await engine.insert('sys_metadata_activation', { + metadata_type: 'position', name: 'qa_retired', package_id: 'com.objectstack.qa.position-catalog-refusal', active: false, + }, { context: seed } as any); await engine.insert('qa_inquiry', [ { id: 'inq_1', subject: 'one' }, { id: 'inq_2', subject: 'two' }, diff --git a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts index 6e11a8d9a2c..be0d6a5541b 100644 --- a/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts +++ b/packages/plugins/plugin-security/src/position-name-fold-warning.test.ts @@ -165,7 +165,10 @@ describe('[#13419] MUST FIRE — a position folded onto its own same-name set wi it('names the ungoverned grant and the two ways out, not just the collision', async () => { const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] }); const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!; - expect(message).toContain('sys_position_permission_set'); + // [ADR-0131 D3/D4] The governed way out is the position definition's + // `permissionSets` — a junction row no longer grants or governs anything. + expect(message).toContain("permissionSets: ['sales_rep']"); + expect(message).not.toContain('sys_position_permission_set'); expect(message).toContain('ungoverned'); expect(message).toMatch(/rename/i); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 2368f214e91..6c1b995f4c0 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3787,9 +3787,16 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { return row[k] === v; }); const baseSchema: any = { name: 'task', fields: { id: { name: 'id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } } }; + // [ADR-0131 D3/D4] The engine registry the security catalog reads the sets from. + const catalogSets = () => [memberDefault, subAdmin, hrAdmin]; const ql = { registerMiddleware: vi.fn(), getSchema: () => baseSchema, + registry: { + getItem: (type: string, name: string) => (type === 'permission' ? catalogSets().find((d: any) => d.name === name) : undefined), + listItems: (type: string) => (type === 'permission' ? catalogSets() : []), + isPackageDisabled: () => false, + }, async find(object: string, opts: any) { const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index e6eeaefad96..eff8ee56ff0 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -7147,8 +7147,9 @@ export class SecurityPlugin implements Plugin { * and app-showcase's `finance` / `legal`) are all of that kind. ⛔ A * false positive on a built-in identity is the most expensive failure * this warning has, and this clause is what prevents it; - * 2. `P` was NOT already requested through the governed channel. Junction - * rows arrive as permission-set NAMES in `context.permissions` + * 2. `P` was NOT already requested through the governed channel. The sets + * a position's definition names (`permissionSets`, ADR-0131 D3/D4) + * arrive as permission-set NAMES in `context.permissions` * (`resolve-authz-context.ts` §6b pushes `ps.name`), as do directly * assigned sets. Either way the grant does not depend on the fold, so * there is nothing ungoverned to report; @@ -7199,11 +7200,10 @@ export class SecurityPlugin implements Plugin { // who can resolve it is already looking. this.logger.warn?.( `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' was granted ` + - `because a POSITION of the same name resolved by name — there is no ` + - `sys_position_permission_set row binding them. This grant is in force and ungoverned: ` + - `it appears in no junction table an operator can inspect. Bind it explicitly ` + - `(sys_position_permission_set: position '${position}' -> permission set '${position}') ` + - `or rename one of the two.`, + `because a POSITION of the same name resolved by name — the position's definition ` + + `does not name it in its permissionSets. This grant is in force and ungoverned: ` + + `no position declares it. Declare it explicitly (position '${position}': ` + + `permissionSets: ['${position}']) or rename one of the two.`, { event: POSITION_NAME_FOLD_EVENT, position, From 4726c1e96d78e6c8345e62119e387fd477a534f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:39:12 +0000 Subject: [PATCH 19/38] changeset: deactivation moves to the activation ledger Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .changeset/15204-s1-position-permission-sets.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md index 10b8874e1b4..542116cdac3 100644 --- a/.changeset/15204-s1-position-permission-sets.md +++ b/.changeset/15204-s1-position-permission-sets.md @@ -27,17 +27,22 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing. -- Deactivation is unchanged. A `sys_position` row or a `sys_permission_set` row with `active: false` still confers nothing: the row is read for that flag and nothing else. For a set, that is the active organization's own row, else the organization-less row. A name with no row carries no flag. +- **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. +- `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. **`@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's exclusion ledger drops the set-row columns the resolver no longer reads (`id`, `system_permissions`, `tab_permissions` and their camel-case spellings). +**`@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-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. **What stops granting at upgrade, and what to do.** +- **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. From 6e058336b3ae8825b96108eccc1c9f4478f17d6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:41:08 +0000 Subject: [PATCH 20/38] cloud-connection: the fake engines answer the activation ledger The resolver reads deactivation from sys_metadata_activation; the install fixtures' grant maps answer it empty, as they answer the other authz tables. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../cloud-connection/src/install-local-principal.fixtures.ts | 1 + .../src/marketplace-install-local-capability-enumeration.test.ts | 1 + .../src/marketplace-install-local-list-posture.test.ts | 1 + .../src/marketplace-install-local-listing-not-loaded.test.ts | 1 + .../src/marketplace-install-local-tenancy-admission.test.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index df763f37b8b..f182e893ee8 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -59,6 +59,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: held.length ? [{ diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index cbd901f1ae9..346bbb9fb50 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -110,6 +110,7 @@ function grantRows(shape: Shape): Record { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: held.length ? [{ diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index 8a72556b500..69aef94f2c5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -89,6 +89,7 @@ const MEMBER_GRANTS: Record = { sys_member: [], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }], sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }], diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts index 781a312041a..81b091239dc 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts @@ -147,6 +147,7 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { ], sys_user_position: [], sys_position: [], + sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, From 997aaa6ceb7d065e15cbedcdca2fcb507a17bbd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:16:44 +0000 Subject: [PATCH 21/38] plugin-security: the #18535 test registry stamps the package id The merged capability declarations are told apart by _packageId; the test registry now stamps it as the engine registry does. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../plugins/plugin-security/src/security-plugin.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 6c1b995f4c0..a49f9ed47b3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4426,7 +4426,11 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 // where `everyone` names the baseline the boot binding above judges. const items = new Map(); const registry = { - registerItem: (type: string, item: any) => { items.set(`${type}:${item.name}`, item); }, + // Stamped with `_packageId` as the engine registry stamps it: the declared-capability + // reader tells the platform's own capability items apart by it. + registerItem: (type: string, item: any, _key?: string, packageId?: string) => { + items.set(`${type}:${item.name}`, packageId ? { ...item, _packageId: packageId } : item); + }, getItem: (type: string, name: string) => items.get(`${type}:${name}`), listItems: (type: string) => [...items.entries()].filter(([k]) => k.startsWith(`${type}:`)).map(([, v]) => v), isPackageDisabled: () => false, From 055f5df39eb53f068b109a09b89188ab05226cec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:12 +0000 Subject: [PATCH 22/38] platform-objects: regenerate the metadata-form bundles for permissionSets; the ledger-guard test double holds the caller's bound Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../src/apps/translations/en.metadata-forms.generated.ts | 6 +++++- .../apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.source-hashes.generated.ts | 2 ++ .../apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.source-hashes.generated.ts | 2 ++ .../apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.source-hashes.generated.ts | 2 ++ .../src/last-admin-guard.activation-ledger.test.ts | 7 ++++++- 8 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 8c9d4ec1246..f66428beb69 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2351,7 +2351,7 @@ export const enMetadataForms: NonNullable = { sections: { position: { label: "Position", - description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree." + description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager): it names the sets it distributes. Capability lives on permission sets; visibility depth lives on the business-unit tree." } }, fields: { @@ -2365,6 +2365,10 @@ export const enMetadataForms: NonNullable = { description: { label: "Description" }, + permissionSets: { + label: "Permission Sets", + helpText: "Permission sets this position distributes, by name (snake_case)." + }, delegatable: { label: "Delegatable", helpText: "Holders may delegate this position themselves, time-boxed and with a reason (ADR-0091 D3). Default false — delegation is admin-only." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 51c85238e24..5e5db3c9356 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const esESMetadataForms: NonNullable = description: { label: "Descripción" }, + permissionSets: { + label: "Permission Sets", + helpText: "Permission sets this position distributes, by name (snake_case)." + }, delegatable: { label: "Delegable", helpText: "Quienes la ocupan pueden delegar esta posición por sí mismos, con un plazo y un motivo (ADR-0091 D3). false por defecto: la delegación es solo para administradores." diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 73f2c7be6b3..dbf4fd0077a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,6 +18,8 @@ */ export const esESGeneratedSourceHashes: Readonly> = { + "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", + "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index cfca34ab2dc..c25ce8bf0e7 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const jaJPMetadataForms: NonNullable = description: { label: "説明" }, + permissionSets: { + label: "Permission Sets", + helpText: "Permission sets this position distributes, by name (snake_case)." + }, delegatable: { label: "セルフ委任を許可", helpText: "保有者が自分でこのポジションを委任できるようにします。期間を区切り、理由の記入が必要です(ADR-0091 D3)。既定は false で、委任は管理者のみです。" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index e1553f4ae95..f472804933a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,6 +18,8 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { + "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", + "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 9267d2a0aa5..381cd138619 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2365,6 +2365,10 @@ export const zhCNMetadataForms: NonNullable = description: { label: "描述" }, + permissionSets: { + label: "Permission Sets", + helpText: "Permission sets this position distributes, by name (snake_case)." + }, delegatable: { label: "可自助委派", helpText: "持有者可自行委派该岗位,需限定时段并填写理由(ADR-0091 D3)。默认 false——委派仅限管理员。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 01317fb3fb6..68c412424a5 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,6 +18,8 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { + "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", + "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", 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 16f2f58144b..0d01ac9ae09 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 @@ -19,7 +19,12 @@ const engineWith = (rows: Array>) => { reads, async find(_object: string, query: any) { reads.push(query?.where); - return rows.filter((r) => r.id === query?.where?.id); + 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; }, }; }; From dca540738c3662098fff3fd4d683853b8c280c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:54:11 +0000 Subject: [PATCH 23/38] platform-objects: translate the position form's permissionSets row in zh-CN, ja-JP and es-ES Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/es-ES.source-hashes.generated.ts | 2 -- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/ja-JP.source-hashes.generated.ts | 2 -- .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/zh-CN.source-hashes.generated.ts | 2 -- 7 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 5e5db3c9356..9d2a86cc7e8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2366,8 +2366,8 @@ export const esESMetadataForms: NonNullable = label: "Descripción" }, permissionSets: { - label: "Permission Sets", - helpText: "Permission sets this position distributes, by name (snake_case)." + label: "Conjuntos de permisos", + helpText: "Conjuntos de permisos que distribuye este puesto, por nombre (snake_case)." }, delegatable: { label: "Delegable", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index dbf4fd0077a..73f2c7be6b3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -18,8 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", - "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.error.label": "786fed84bd8d5a32", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index c25ce8bf0e7..c93c20a9ae0 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2366,8 +2366,8 @@ export const jaJPMetadataForms: NonNullable = label: "説明" }, permissionSets: { - label: "Permission Sets", - helpText: "Permission sets this position distributes, by name (snake_case)." + label: "権限セット", + helpText: "この役職が配布する権限セット(名前で指定、snake_case)。" }, delegatable: { label: "セルフ委任を許可", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index f472804933a..e1553f4ae95 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -18,8 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", - "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.bcc_addresses.label": "8674899b7b6d5126", "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 338d229f583..eb64841c734 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1180,7 +1180,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 668 since the object form offers `enable.approvalsVisibleToReaders`, // the per-object opt-in for the read-only record-reader approval tier — // one new row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(668); + // 669 since the position form offers `permissionSets` (ADR-0131 D3/D4), + // the sets a position distributes — one new row label, authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(669); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 381cd138619..3177edf1270 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2366,8 +2366,8 @@ export const zhCNMetadataForms: NonNullable = label: "描述" }, permissionSets: { - label: "Permission Sets", - helpText: "Permission sets this position distributes, by name (snake_case)." + label: "权限集", + helpText: "该岗位分配的权限集,按名称填写(snake_case)。" }, delegatable: { label: "可自助委派", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 68c412424a5..01317fb3fb6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -18,8 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "metadataForms.position.fields.permissionSets.helpText": "475b3ac5aea345dc", - "metadataForms.position.fields.permissionSets.label": "4addfe2b8b28b78a", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", From 295f3bc51192e17535d0563f5a04cbe36c1f0d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:43:55 +0000 Subject: [PATCH 24/38] plugin-auth: the last-admin guard reads the admin set from the catalog and its switch from the ledger Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../plugin-auth/src/last-admin-guard.ts | 610 ++++++++---------- 1 file changed, 253 insertions(+), 357 deletions(-) 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', ); } From 650862529c7007828b6eb454786332cf44ef9436 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:45:56 +0000 Subject: [PATCH 25/38] plugin-auth, plugin-sharing: the self-registration, default-organization and sharing-rule readers ask the catalog and the ledger Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 47 +++++--- .../plugin-auth/src/catalog-set-in-effect.ts | 61 ++++++++++ .../src/ensure-default-organization.ts | 19 +++- .../src/sharing-rule-service.ts | 106 +++++++----------- 4 files changed, 150 insertions(+), 83 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/catalog-set-in-effect.ts 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/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/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-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 22fe52798d1..8da25024d43 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,55 @@ 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.** On a minimal stack the ledger may not + * be registered or provisioned, and 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 { 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; } } From 4168f03dcb8ea42171f4ba06443eaa4df7694b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:48:32 +0000 Subject: [PATCH 26/38] plugin-sharing: pin the sharing-rule deactivation on the activation ledger Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../plugin-sharing/src/sharing-rule.test.ts | 114 ++++++++++-------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 57abaa031f8..f00dc5d3107 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,63 @@ 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('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([]); }); }); From 4b7ea40bbd30627e98b6822ed76e2debce0bf2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:00:15 +0000 Subject: [PATCH 27/38] plugin-auth: fixtures declare the catalog their set rows name; the row active pins move to the ledger Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../plugin-auth/src/audience-posture.test.ts | 11 +++++++- .../plugin-auth/src/auth-plugin.test.ts | 5 ++++ .../src/default-org-bootstrap-once.test.ts | 3 +++ .../src/ensure-default-organization.test.ts | 6 +++-- .../src/find-envelope-limb-removal.test.ts | 16 +++++------ .../src/grant-readers-by-name.golden.test.ts | 27 +++++++++++++------ .../src/grant-readers-unnamed-grant.test.ts | 11 ++++++-- .../src/logger-receiver-detach.test.ts | 6 ++--- .../scim-deactivation-reconcile-user.test.ts | 4 +++ .../signup-existing-address-refusal.test.ts | 5 +++- 10 files changed, 69 insertions(+), 25 deletions(-) diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts index 0a4da720a74..ff60408657f 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); 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/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..6f5ca5075b4 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 } 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)', () => { 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/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; From ef51f2e5a8d95fcf8339c2114db39c62d2d51450 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:03:44 +0000 Subject: [PATCH 28/38] plugin-auth: the guard's ledger and re-pricing pins judge ledger writes; the set row is no standing input Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- ...last-admin-guard.activation-ledger.test.ts | 266 +++++++++++++++--- .../last-admin-guard.config-anchor.test.ts | 37 ++- .../src/last-admin-guard.re-pricing.test.ts | 123 +++++--- .../src/last-admin-standing-keys.test.ts | 2 +- 4 files changed, 333 insertions(+), 95 deletions(-) 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-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); }); }); From 96f94c1520968a5ae14cd9cb904b3145cd59e06e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:06:47 +0000 Subject: [PATCH 29/38] plugin-auth: the guard's path-4 pins switch the set off in the ledger; the set row is pinned as no standing input Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../plugin-auth/src/last-admin-guard.test.ts | 676 ++++++------------ 1 file changed, 228 insertions(+), 448 deletions(-) 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(); }); From d4312570d83a885384d457aebe46490ca4ee70d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:14:41 +0000 Subject: [PATCH 30/38] plugin-auth, plugin-sharing: changesets; the admission and default-organization readers are pinned on the catalog and the ledger Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .../15204-s2b-plugin-auth-catalog-readers.md | 24 ++++++++++++++ ...-s2b-plugin-sharing-ledger-deactivation.md | 9 ++++++ .../plugin-auth/src/audience-posture.test.ts | 31 +++++++++++++++++++ .../src/ensure-default-organization.test.ts | 22 ++++++++++++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/15204-s2b-plugin-auth-catalog-readers.md create mode 100644 .changeset/15204-s2b-plugin-sharing-ledger-deactivation.md 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..f0ddb695026 --- /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. + +**FROM → TO** for a caller of the removed exports: `PERMISSION_SET_STANDING_KEYS` → `ACTIVATION_LEDGER_STANDING_KEYS` (the table the resolver reads deactivation from); `refuseLedgerSwitchingAdminOff` → nothing to call, `registerLastAdminGuard` judges ledger writes itself. + +**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..bd6e72d9d94 --- /dev/null +++ b/.changeset/15204-s2b-plugin-sharing-ledger-deactivation.md @@ -0,0 +1,9 @@ +--- +'@objectstack/plugin-sharing': patch +--- + +fix(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-②: no + +A sharing rule whose recipient is a position materializes no share while that position is switched off: 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. The `sys_position` row's `active` is no longer read, so a deactivation made in Setup (which writes that column) stops neither the resolver nor sharing rules until the upgrade ceremony converts it into a ledger row. A name with no ledger row keeps sharing, a membership-derived position name included, and a ledger read that fails still grants for the pass, as the row read did. diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts index ff60408657f..b0f7a9bd66d 100644 --- a/packages/plugins/plugin-auth/src/audience-posture.test.ts +++ b/packages/plugins/plugin-auth/src/audience-posture.test.ts @@ -815,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/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 6f5ca5075b4..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,7 +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 } from './__tests__/security-catalog.testkit.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 @@ -119,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); From bdb2704dc2d00638974fd90779d6a7399ce36c4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:17:38 +0000 Subject: [PATCH 31/38] changeset: plugin-auth's breaking change carries no migration prescription Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- .changeset/15204-s2b-plugin-auth-catalog-readers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/15204-s2b-plugin-auth-catalog-readers.md b/.changeset/15204-s2b-plugin-auth-catalog-readers.md index f0ddb695026..7a1ae3d843a 100644 --- a/.changeset/15204-s2b-plugin-auth-catalog-readers.md +++ b/.changeset/15204-s2b-plugin-auth-catalog-readers.md @@ -6,7 +6,7 @@ feat(plugin-auth)!: the last-administrator guard, the self-registration admissio 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. @@ -17,7 +17,7 @@ Clause-②: yes (narrowing: a zero-administrator write is refused when an unscop - 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. -**FROM → TO** for a caller of the removed exports: `PERMISSION_SET_STANDING_KEYS` → `ACTIVATION_LEDGER_STANDING_KEYS` (the table the resolver reads deactivation from); `refuseLedgerSwitchingAdminOff` → nothing to call, `registerLastAdminGuard` judges ledger writes itself. +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. From de4d000e80ca7b3d23b779d1b6d19fb954c4e0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:18:07 +0000 Subject: [PATCH 32/38] plugin-auth: the guard's registration comment names the ledger, not the set row Claude-Session: https://claude.ai/code/session_01ByzDx6dv5xWV1SCpCviLRU Co-authored-by: Claude --- packages/plugins/plugin-auth/src/auth-plugin.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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, From 96fb3e9811b91049fbff72abc74893d707a9e8af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:18:29 +0000 Subject: [PATCH 33/38] patch round 1: the four red CI packages, and the changeset names the admin-standing surface change - metadata-protocol: position serves 13 top-level properties (permissionSets). - mcp: the stored-content-hash fake engine refuses only the two metadata tables it serves, not the activation ledger the resolver reads under a system context. - spec: the position-permission-sets-declared guidance names no tracker id. - dogfood: the FLS and /me/apps probes author their sets through the data door, as Setup does, so the write-through stores the definition the catalog resolves. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../15204-s1-position-permission-sets.md | 1 + ...io-data-bridge.stored-content-hash.test.ts | 4 +- ...l.meta-types-degenerate-derivation.test.ts | 6 ++- ...apps-and-everyone-baseline.dogfood.test.ts | 37 ++++++++----------- ...owcase-fls-read-mask-strip.dogfood.test.ts | 20 +++++----- .../18.position-permission-sets-declared.ts | 2 +- packages/spec/src/migrations/registry.ts | 2 +- 7 files changed, 35 insertions(+), 37 deletions(-) diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md index 542116cdac3..809abe3d6e3 100644 --- a/.changeset/15204-s1-position-permission-sets.md +++ b/.changeset/15204-s1-position-permission-sets.md @@ -28,6 +28,7 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing. - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. +- The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. diff --git a/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts b/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts index e956f69faf5..52d19f1f80f 100644 --- a/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts +++ b/packages/mcp/src/stdio-data-bridge.stored-content-hash.test.ts @@ -65,7 +65,9 @@ function fakeEngine(opts: { owner?: string; provider?: boolean } = {}) { find: vi.fn(async (object: string, query?: any, options?: any) => { const context = options?.context ?? query?.context; if (object === 'sys_api_key') return [{ id: 'k1', user_id: owner, revoked: false }]; - if (object.startsWith('sys_metadata') && context?.userId !== ADMIN) { + // The two metadata tables this suite serves — not the activation ledger + // the authorization resolver reads under a system context (ADR-0131 D3). + if ((object === 'sys_metadata' || object === 'sys_metadata_history') && context?.userId !== ADMIN) { throw Object.assign(new Error(`Permission denied: cannot read '${object}'`), { code: 'PERMISSION_DENIED', status: 403 }); } const rows = (TABLES[object] ?? []).filter((row) => matches(row, query?.where)); diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 3954d5dc631..d99eb33ca93 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -312,11 +312,13 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * deadline and when it is settled), and 77 → 78 when it gained * `conditionalFormatting` (#22228, a field's cell formatting rules). * `dashboard` moved 21 → 22 the same way when it gained - * `requiredPermissions` (#22611, the audience gate). + * `requiredPermissions` (#22611, the audience gate). `position` moved + * 12 → 13 the same way when it gained `permissionSets` (ADR-0131 D3/D4, + * the sets a position distributes). */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 22, dataset: 16, field: 78, flow: 23, - hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14, + hook: 22, object: 45, page: 25, position: 13, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index 5cc137795b6..4da3a10c1e7 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -109,16 +109,16 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); // Author the property: a set whose ONLY job is tabPermissions.hidden. - const hideSet = await ql.insert( - 'sys_permission_set', - { + // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the + // write-through stores the definition the security catalog resolves. A + // system-context row insert has no definition and grants nothing. + const hideSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'tab_probe_hide', label: 'Tab probe — hide showcase', tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), - }, - { context: SYS }, - ); - const hideSetId = hideSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; + }); + expect(hideSet.status, 'set authored through the data door').toBeLessThan(300); + const hideSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; expect(hideSetId, 'hide set stored').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: hideSetId }, { context: SYS }); @@ -128,16 +128,13 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — // most-visible wins across the principal's sets, matching hono's tabRank. - const showSet = await ql.insert( - 'sys_permission_set', - { + const showSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'tab_probe_show', label: 'Tab probe — show showcase', tab_permissions: JSON.stringify({ showcase_app: 'visible' }), - }, - { context: SYS }, - ); - const showSetId = showSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; + }); + expect(showSet.status, 'set authored through the data door').toBeLessThan(300); + const showSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: showSetId }, { context: SYS }); const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -177,19 +174,15 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // some wildcard happened to grant create. const memberUser = await ql.findOne('sys_user', { where: { email: 'baseline-member@verify.test' }, context: SYS }); expect(memberUser?.id, 'baseline member resolved').toBeTruthy(); - const inquirySet = await ql.insert( - 'sys_permission_set', - { + const inquirySet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: 'baseline_inquiry_probe', label: 'Baseline probe — inquiry create, deliberately no delete', object_permissions: JSON.stringify({ showcase_inquiry: { allowRead: true, allowCreate: true }, }), - }, - { context: SYS }, - ); - const inquirySetId = inquirySet?.id - ?? (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; + }); + expect(inquirySet.status, 'set authored through the data door').toBeLessThan(300); + const inquirySetId = (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; expect(inquirySetId, 'probe set stored').toBeTruthy(); await ql.insert( 'sys_user_permission_set', diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index 4de8520e3a3..fa73b5dee91 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -112,13 +112,14 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked // `showcase_project`, so this set contributes nothing but the field deny — // which is what makes the assertions below attributable to FLS alone. // - // `active: true` is load-bearing: the security plugin's DB loader filters - // deactivated rows out (`isRowActive`), and an inactive set grants nothing, - // including nothing to deny — the field would come back readable and every - // assertion here would fail as if the strip were broken. - await ql.insert( - 'sys_permission_set', - { + // A deactivated set grants nothing, including nothing to deny: the field + // would come back readable and every assertion here would fail as if the + // strip were broken. Deactivation is the activation ledger's (ADR-0131 D3); + // this set has no ledger row, so it is in effect. + // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the + // write-through stores the definition the security catalog resolves. A + // system-context row insert has no definition and grants nothing. + const authored = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { name: SCRATCH_SET, label: 'Scratch: FLS read deny on showcase_project.budget', description: @@ -132,9 +133,8 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked }), system_permissions: JSON.stringify([]), active: true, - }, - { context: SYS }, - ); + }); + expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); expect(ps?.id, 'scratch permission set authored').toBeTruthy(); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; diff --git a/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts b/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts index f47c7ca2242..fe1e01d58fa 100644 --- a/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts +++ b/packages/spec/src/migrations/entries/semantic/18.position-permission-sets-declared.ts @@ -27,7 +27,7 @@ export const entry: SemanticMigration = { + 'The conversion of stored rows is data, not a metadata rewrite: organizations that bound different ' + 'sets to one position name are reported conflicting and never merged (D10 fate 4), so no chain step ' + 'can apply it on its own. Under a wall a position or set an organization authored in Setup has no ' - + 'definition at all and stops granting (ruling #15196 Q3 = A); a platform administrator re-creates it as ' + + 'definition at all and stops granting (ruled: such a row is not promoted); a platform administrator re-creates it as ' + 'environment metadata under the same name to keep it. ADR-0087.', acceptanceCriteria: 'Every position whose holders should hold a permission set names that set in `permissionSets` on its ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5d96c752e8e..57227501a15 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -18351,7 +18351,7 @@ const step18: MigrationStep = { + 'The conversion of stored rows is data, not a metadata rewrite: organizations that bound different ' + 'sets to one position name are reported conflicting and never merged (D10 fate 4), so no chain step ' + 'can apply it on its own. Under a wall a position or set an organization authored in Setup has no ' - + 'definition at all and stops granting (ruling #15196 Q3 = A); a platform administrator re-creates it as ' + + 'definition at all and stops granting (ruled: such a row is not promoted); a platform administrator re-creates it as ' + 'environment metadata under the same name to keep it. ADR-0087.', acceptanceCriteria: 'Every position whose holders should hold a permission set names that set in `permissionSets` on its ' From fb2855fb34cc9d40e3af019499b8b93a4bed44e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:56:18 +0000 Subject: [PATCH 34/38] changeset: platform-objects ships the regenerated position form bundles Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .changeset/15204-s1-position-permission-sets.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/15204-s1-position-permission-sets.md index 809abe3d6e3..f00986eb63d 100644 --- a/.changeset/15204-s1-position-permission-sets.md +++ b/.changeset/15204-s1-position-permission-sets.md @@ -3,6 +3,7 @@ '@objectstack/core': minor '@objectstack/plugin-security': minor '@objectstack/plugin-auth': minor +'@objectstack/platform-objects': patch --- feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) @@ -26,7 +27,7 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. -- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing. +- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. @@ -38,6 +39,8 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi **`@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/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. **What stops granting at upgrade, and what to do.** From 2c3a59889107086ffeeff72e97dbcd31dc6e6507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:26:44 +0000 Subject: [PATCH 35/38] docs: a position's sets are its permissionSets; a junction row grants nothing The permissions docs, the delegated-admin TSDoc and the page audience refusal point at the position definition's permissionSets; the suggestion Confirm paragraph says the row it writes does not grant; the grants-cache watch list names sys_metadata_activation. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- content/docs/data-modeling/objects.mdx | 6 ++--- .../docs/deployment/environment-variables.mdx | 2 +- content/docs/permissions/authorization.mdx | 7 +++--- .../permissions/delegated-administration.mdx | 4 +-- content/docs/permissions/permission-sets.mdx | 25 +++++++++++++------ content/docs/permissions/positions.mdx | 13 +++++----- content/docs/permissions/profiles.mdx | 2 +- content/docs/references/ui/page.mdx | 2 +- packages/spec/src/identity/membership-role.ts | 4 +-- packages/spec/src/ui/page.zod.ts | 4 +-- 10 files changed, 41 insertions(+), 28 deletions(-) diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index b772f3a5e32..9e02192755e 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -332,9 +332,9 @@ export const SysHolidayCalendar = ObjectSchema.create({ ``` The reason is leverage, not authorization. The bucket's charter members are the -RBAC link tables — `sys_user_position`, `sys_user_permission_set`, -`sys_position_permission_set` — which are the grant surface of the entire -permission model. Every row a CSV import writes still passes the delegated-admin +RBAC link tables — `sys_user_position` and `sys_user_permission_set`, the grant +surface of the entire permission model, and `sys_position_permission_set`, +whose rows no longer grant (a position's sets are its `permissionSets`). Every row a CSV import writes still passes the delegated-admin gate, RLS and permission-set adjudication one at a time, so an admin who cannot grant a permission set by hand cannot grant it by file either. What differs is blast radius: row by row, one misclick affects one person; one wrong CSV is a diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index bd22e11b997..3d694a5eb19 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -67,7 +67,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. | | `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). | | `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). | -| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | +| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`, `sys_metadata_activation`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. | --- diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 426e851a9cb..d9c6462b0d9 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -34,7 +34,7 @@ Authorization splits into three concerns that stay decoupled: resource merely *references* one by name (see Requirement, below). 2. **Assignment** — *who holds it* — permission sets / positions / user bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`, - `sys_position_permission_set`, `sys_user_position`). Runtime records, + `sys_user_position`, and the sets a position names in its `permissionSets`). Runtime records, maintained by admins in Setup — and, since ADR-0090 D12, a **governed** surface: writing them requires tenant-level administration or a covering delegated `adminScope`, never just CRUD on the tables. @@ -232,8 +232,9 @@ one of two doors, each writing only what it owns: unaffected while a set is still a draft (drafts never enter the active resolve). - **Environment-admin door** (`metadata-admin`) — the cross-package all-objects - matrix plus subject **assignment** (`sys_user_permission_set`, - `sys_position_permission_set`), edited **live** (config). It owns env-authored + matrix plus subject **assignment** (`sys_user_permission_set`), edited + **live** (config); the sets a position distributes are its definition's + `permissionSets`. It owns env-authored sets (`managedBy` `platform`/`user`) and assignments — not package sets. Since ADR-0094 the set **definition** itself has one authoritative store — the metadata layer: an env-door write to `sys_permission_set` (the Setup diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 9bf0bfad9a3..ba00c335783 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -158,8 +158,8 @@ Two independent switches, and you need both: | An `adminScope`-carrying permission set | *What* the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing | The role carries no ObjectStack authority of its own: it passes through as a -position name, and with no `sys_position_permission_set` binding it resolves to -nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint +position name, and with no position of that name declaring `permissionSets` it +resolves to nothing. **Role = can reach the endpoint. `adminScope` = what the endpoint permits.** A default deployment is unaffected — nobody holds the role until you set it. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 025181fc002..20f43a137d6 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -211,7 +211,8 @@ process reload. ## Assigning permission sets -Grants are plain data rows: +A direct grant is a data row; a position's sets are declared on the position's +definition: ```typescript // Grant a set to a single user (direct grant) @@ -220,13 +221,21 @@ await objectql.object('sys_user_permission_set').insert({ permission_set_id: psId, }); -// Bind a set to a position (everyone holding it gets the set) -await objectql.object('sys_position_permission_set').insert({ - position_id: positionId, - permission_set_id: psId, +// A position names the sets it distributes (everyone holding it gets them) +import { definePosition } from '@objectstack/spec/identity'; + +export const salesRep = definePosition({ + name: 'sales_rep', + label: 'Sales Representative', + permissionSets: ['crm_sales_user'], }); ``` +Each name in `permissionSets` is resolved in the environment's catalog when a +holder's grants are resolved; a name the catalog does not hold confers nothing. +A `sys_position_permission_set` row confers nothing either: the position's +definition is the only binding that grants. + These writes are **governed** (ADR-0090 D12): tenant-level admins pass through to the ordinary checks; a delegated admin needs a covering `adminScope`; plain CRUD on the tables grants nothing by itself. Bindings to @@ -264,9 +273,11 @@ accepted set — it previously reached the query as a filter nothing matched, so a mistyped status returned an empty list that read as "no suggestions". All three require a tenant-level administrator (the anchors are tenant-level -only — D12). Confirm writes the `sys_position_permission_set` row **as the +only — D12). Confirm writes a `sys_position_permission_set` row **as the caller**, so the audience-anchor gate re-checks the forbidden-bits predicate -at the data layer; a suggestion whose binding was created out-of-band (boot +at the data layer. That row does not grant the set: what `everyone` grants is +the `permissionSets` its definition names, so a confirmed suggestion changes no +one's access until the set is named there. A suggestion whose binding was created out-of-band (boot baseline, manual bind) is marked `confirmed` automatically, and uninstalling the package prunes its pending suggestions. Studio surfaces pending suggestions after marketplace installs and in the Access pillar. diff --git a/content/docs/permissions/positions.mdx b/content/docs/permissions/positions.mdx index 203ce27aa89..3856ee87d39 100644 --- a/content/docs/permissions/positions.mdx +++ b/content/docs/permissions/positions.mdx @@ -5,9 +5,10 @@ description: "Positions are flat groups that hand permission sets to users — t --- A **position** (岗位) is a named, assignable distribution group: users hold -positions (`sys_user_position` rows), positions bind permission sets -(`sys_position_permission_set` rows), and a user's capability is the union of -every set reached that way plus direct grants. Positions carry **no capability +positions (`sys_user_position` rows), a position names the permission sets it +distributes in `permissionSets` on its definition, and a user's capability is +the union of every set reached that way plus direct grants. A +`sys_position_permission_set` row grants nothing. Positions carry **no capability of their own** — they only decide *who gets which sets*. > **Formerly "roles".** ADR-0090 D3 renamed the concept in one step and @@ -26,9 +27,9 @@ of their own** — they only decide *who gets which sets*. import type { Position } from '@objectstack/spec/identity'; export const positions: Position[] = [ - { name: 'sales_manager', label: 'Sales Manager' }, - { name: 'sales_rep', label: 'Sales Representative' }, - { name: 'service_agent', label: 'Service Agent' }, + { name: 'sales_manager', label: 'Sales Manager', permissionSets: ['crm_sales_manager'] }, + { name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }, + { name: 'service_agent', label: 'Service Agent', permissionSets: ['service_agent_access'] }, ]; ``` diff --git a/content/docs/permissions/profiles.mdx b/content/docs/permissions/profiles.mdx index 2efbf245d7d..27df81986ce 100644 --- a/content/docs/permissions/profiles.mdx +++ b/content/docs/permissions/profiles.mdx @@ -18,7 +18,7 @@ Everything a profile did is expressed with the remaining vocabulary: |:---|:---| | A user's baseline permissions | A permission set bound to the **`everyone`** audience anchor (default grants for all authenticated users), or the configured baseline set (`member_default`) — both **additive** (ADR-0090 D5) | | A package's default access posture | `isDefault: true` on the package's permission set — an install-time **suggestion** to bind it to `everyone`; the admin confirms, nothing auto-binds | -| A job function's permissions ("Sales Rep") | An ordinary permission set bound to a **[position](/docs/permissions/positions)** (`sales_rep`) via `sys_position_permission_set` | +| A job function's permissions ("Sales Rep") | An ordinary permission set named in a **[position](/docs/permissions/positions)**'s `permissionSets` (`sales_rep`) | | Tab/app visibility defaults | `tabPermissions` on any permission set (unchanged) | The former `isProfile` flag was deleted from `PermissionSetSchema`; the diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 74e9453e0f8..9a0a374e261 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -178,7 +178,7 @@ View filter rule | **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | | -| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | diff --git a/packages/spec/src/identity/membership-role.ts b/packages/spec/src/identity/membership-role.ts index b73d6a8c76a..2285e5e3ee8 100644 --- a/packages/spec/src/identity/membership-role.ts +++ b/packages/spec/src/identity/membership-role.ts @@ -75,8 +75,8 @@ export const MEMBERSHIP_ROLE_MEMBER = 'member'; * `/organization/invite-member` **without** being an org admin. * * It carries NO ObjectStack authority by construction: `mapMembershipRole` - * passes it through as a position name, and with no - * `sys_position_permission_set` binding that name resolves to nothing. + * passes it through as a position name, and with no position of that name + * declaring `permissionSets` that name resolves to nothing. * Reaching the endpoint is not authority to place — placement authority comes * solely from a separately-granted `adminScope`. Role = *can reach the * endpoint*; adminScope = *what the endpoint permits*. diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 2c9ad4ab04c..21fd74dc199 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -836,7 +836,7 @@ const PAGE_ASSIGNED_PROFILES_RETIRED = + 'gated nothing: no renderer, route or metadata read door ever read the key, so a page that ' + '"assigned profiles" stayed open to every caller who could reach it. Delete the key. Page ' + "audience is the permission set's: gate the DATA the page shows with the object's permission " - + 'sets, and bind those sets to people through positions (`sys_position_permission_set`) — ' + + "sets, and bind those sets to people through positions (named in a position's `permissionSets`) — " + 'those are the checks the runtime actually runs. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; @@ -849,7 +849,7 @@ const PAGE_AUDIENCE_WRONG_LAYER = '`profiles` / `assignedTo` are not page keys (ADR-0090 D2: no Profile concept). Page ' + "audience is the permission set's — gate the DATA the page shows with the object's " + 'permission sets, and bind those sets to people through positions ' - + '(`sys_position_permission_set`), never on the page itself.'; + + "(named in a position's `permissionSets`), never on the page itself."; // ─── The print page (#22158: card ① of the #8346 ruling) ─────────────────── // From d0cab9f80d8394171d72f006d8c7c11b25e2eb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:08:24 +0000 Subject: [PATCH 36/38] port #22750: render-projection-diff generates the base's git-ignored migration registry Byte-identical to that PR's head 2a4ccb4878 for render-projection-diff.ts and its test; a no-op once main carries it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../scripts/render-projection-diff.test.ts | 110 ++++++++++++++++++ .../spec/scripts/render-projection-diff.ts | 51 +++++++- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/spec/scripts/render-projection-diff.test.ts b/packages/spec/scripts/render-projection-diff.test.ts index 550740f12c8..e095311e290 100644 --- a/packages/spec/scripts/render-projection-diff.test.ts +++ b/packages/spec/scripts/render-projection-diff.test.ts @@ -16,13 +16,16 @@ import path from 'node:path'; import { ARTIFACT_NAME, + BASE_REGISTRY, SUMMARY_DIFF_CAP, describeDeltas, fenceFor, + generateBaseRegistry, idDeltas, renderProjectionDiff, renderSummary, type Generate, + type RunScript, } from './render-projection-diff'; let tmp: string; @@ -149,6 +152,113 @@ describe('render-projection-diff — the generated diff on a pull request', () = }); }); +describe('render-projection-diff — a base whose migration registry is git-ignored', () => { + /** A base `packages/spec` laid out as its archive would be: the generator and/or the registry, or neither. */ + function baseSpecDir(has: { generator: boolean; registry: boolean }): string { + const dir = path.join(tmp, 'base-tree', 'packages', 'spec'); + fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + if (has.generator) fs.writeFileSync(path.join(dir, 'scripts', BASE_REGISTRY.generator), ''); + if (has.registry) { + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// committed\n'); + } + return dir; + } + + /** A fake runner that records each call and, unless told otherwise, writes the registry the way the generator does. */ + function recorder(result: { status: number; write: boolean; output?: string }) { + const calls: [string, string][] = []; + const run: RunScript = (dir, script) => { + calls.push([dir, script]); + if (result.write) fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// generated\n'); + return { status: result.status, output: result.output ?? '' }; + }; + return { calls, run }; + } + + it('an archive with the generator but no registry runs the BASE generator in the base tree', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(true); + expect(calls).toEqual([[dir, 'build-migration-registry.ts']]); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// generated\n'); + }); + + it('a base that committed its registry keeps it, and a base without the generator is left as it is', () => { + for (const has of [{ generator: true, registry: true }, { generator: false, registry: true }, { generator: false, registry: false }]) { + fs.rmSync(path.join(tmp, 'base-tree'), { recursive: true, force: true }); + const dir = baseSpecDir(has); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(false); + expect(calls).toEqual([]); + if (has.registry) expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// committed\n'); + } + }); + + it('a base registry generator that fails, or exits 0 without writing, is thrown with its output', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + + // First line names the generator and its exit; the generator's output follows it — the + // shape the failure summary splits on. + expect(() => generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run)).toThrow( + /^build-migration-registry\.ts exited 1\b[^\n]*\nentry failed to parse$/, + ); + expect(() => generateBaseRegistry(dir, recorder({ status: 0, write: false }).run)).toThrow( + /^build-migration-registry\.ts exited 0\b[^\n]*src\/migrations\/registry\.ts/, + ); + }); + + it('a base whose registry cannot be generated is RED, named as the base', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + const generate: Generate = (_dir, script, out) => { + fs.writeFileSync(out, script === 'build-spec-changes.ts' ? BASE.json : BASE.guide); + return { status: 0, output: '' }; + }; + const head = path.join(tmp, 'head-tree', 'packages', 'spec'); + fs.mkdirSync(head, { recursive: true }); + const outDir = path.join(tmp, 'out'); + const result = renderProjectionDiff({ + repoRoot: tmp, + baseSha: 'b'.repeat(40), + baseLabel: 'HEAD^1 = bbbbbbbbbb', + outDir, + headSpecDir: head, + generate, + materializeBase: () => { + generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run); + return dir; + }, + }); + + expect(result.code).toBe(1); + expect(result.headline).toContain('base HEAD^1 = bbbbbbbbbb: build-migration-registry.ts exited 1'); + expect(result.summary).toContain('entry failed to parse'); + }); + + it('the default runner executes the base generator by its own path, with the base package as cwd', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + // Resolves its package root from its own path, as the real generator does. + fs.writeFileSync( + path.join(dir, 'scripts', BASE_REGISTRY.generator), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');", + "mkdirSync(join(pkgRoot, 'src', 'migrations'), { recursive: true });", + "writeFileSync(join(pkgRoot, 'src', 'migrations', 'registry.ts'), `// cwd ${process.cwd()}\\n`);", + '', + ].join('\n'), + ); + + expect(generateBaseRegistry(dir)).toBe(true); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe(`// cwd ${fs.realpathSync(dir)}\n`); + }); +}); + describe('render-projection-diff — the pieces', () => { it('idDeltas names ids gained and lost per record, and skips records that did not move', () => { const deltas = idDeltas(manifest(['a', 'b'], ['c1']), manifest(['b', 'z'], ['c1', 'c2'])); diff --git a/packages/spec/scripts/render-projection-diff.ts b/packages/spec/scripts/render-projection-diff.ts index e299077fa41..07690f29a8e 100644 --- a/packages/spec/scripts/render-projection-diff.ts +++ b/packages/spec/scripts/render-projection-diff.ts @@ -27,6 +27,8 @@ * base ``'s own generators, run in a `git archive` of `` that * borrows this checkout's `node_modules` — so the base side is what the * base's code generated, never this change's generator applied to it. + * A base whose migration registry is git-ignored gets it written first, + * by its own generator (`generateBaseRegistry`). * * ⚠️ A base whose generators predate `--out` writes its committed path instead * (inside the throwaway archive, never this checkout). That is read in its @@ -86,8 +88,11 @@ export interface GeneratorRun { /** Runs `/scripts/