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