From 7b518122a40608d27dc052d05931b14c4816bb55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:31:05 +0000 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 7c942569429a558c3b9907dd43283693028a5c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:44:36 +0000 Subject: [PATCH 24/44] plugin-security: the security readers read the catalog and the activation ledger (stage 2a, WIP) Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- packages/core/src/security/index.ts | 4 + .../src/security/resolve-authz-context.ts | 7 +- .../src/delegated-admin-gate.ts | 219 +++++++------ .../src/engine-find-bare-array.pin.test.ts | 142 +-------- .../plugin-security/src/errors.test.ts | 1 - .../plugins/plugin-security/src/errors.ts | 55 ---- .../plugin-security/src/explain-engine.ts | 95 +++--- .../src/position-catalog-refusal.ts | 219 ++++++------- .../src/position-write-through.ts | 63 ++++ .../plugin-security/src/security-plugin.ts | 291 +++++------------- 10 files changed, 413 insertions(+), 683 deletions(-) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 0a2181eb0ca..edbfa2b8e1b 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -156,6 +156,10 @@ export { // [#20580] The session arm's membership check (#15409 ruling B), so the // permission explainer resolves the user it explains through the same one. vetOrganizationClaim, + // [ADR-0049, ADR-0131 D3, ADR-0126 §4] The one deactivation read of the + // security catalog — the `sys_metadata_activation` ledger, never a catalog + // row's `active` column — shared with the explainer and the security plugin. + readDisabledCatalogNames, resolveLocalizationContext, type ResolvedAuthzContext, type ResolveAuthzInput, diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index cc8039bc6f2..6fbbb9d4852 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -771,8 +771,13 @@ function ledgerUnregistered(ql: any): boolean { * 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. + * + * Exported so every other reader of "is this switched off" — the permission + * explainer's dropped-grant report, the security plugin's position-name fold — + * asks this one read instead of a second copy, and explain and enforce cannot + * disagree about deactivation. */ -async function readDisabledCatalogNames( +export async function readDisabledCatalogNames( ql: any, positions: readonly string[], sets: readonly string[], diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index b3b72839991..85e019e7cb6 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -37,7 +37,7 @@ * reconciliation are unaffected). */ -import { isGrantActive } from '@objectstack/core'; +import { isGrantActive, securityCatalogReaderOf, type SecurityCatalogReader } from '@objectstack/core'; import type { AdminScope, AdminScopeParsed, PermissionSet } from '@objectstack/spec/security'; import { PermissionDeniedError } from './errors.js'; import { @@ -45,13 +45,11 @@ import { rowOrganizationId, seedCtx as organizationScopedCtx, } from './organization-scope.js'; -import { GRANT_SET_ID_FIELD, grantSetNameOf, readGrantSetRows } from './grant-permission-set-name.js'; +import { GRANT_SET_ID_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; const SYSTEM_CTX = { isSystem: true } as const; /** - * Max by-name candidates read when resolving a scope's business-unit anchor, - * or a position's own `sys_position` row (the same shape: `sys_position.name` - * is per-organization too — see `resolveOwnPosition`). + * Max by-name candidates read when resolving a scope's business-unit anchor. * * `sys_business_unit.name` carries NO uniqueness — the object's only unique * index is `(code, organization_id)` — so under a walled posture two @@ -76,11 +74,12 @@ const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000; * active organization, never a scan across all of them. * * The same spelling as `SecurityPlugin.callerOrganizationId`, and deliberately - * so: the `adminScope` this gate resolves arrives on a permission set the - * plugin loaded through `dbLoaderFor(callerOrganizationId(context))`, i.e. out - * of THAT organization's catalog. Resolving the scope's business-unit anchor in - * any other organization pairs an authority minted in one tenant with a tree - * owned by another — which is what an unscoped by-name read did. + * so: the `adminScope` this gate resolves arrives on a permission set resolved + * for THAT organization's caller. Resolving the scope's business-unit anchor in + * any other organization pairs an authority held in one tenant with a tree + * owned by another — which is what an unscoped by-name read did. (The set + * itself is the security catalog's, which is environment-level — ADR-0131 D3; + * the business units are not.) * * Under the `single` posture this is the Default Organization: since ADR-0131 * C1 it exists from boot, and every session on a stock `single` deployment — @@ -237,6 +236,12 @@ interface HeldScope { subtree: Set; } +/** A permission set a position distributes, as the containment check reads it. */ +interface BoundSet { + name: string; + adminScope?: unknown; +} + function rowsOf(opCtx: any): any[] { const d = opCtx?.data; if (Array.isArray(d)) return d; @@ -465,18 +470,20 @@ export class DelegatedAdminGate { ): Promise { const ql = this.deps.ql; const organizationId = callerOrganizationId(callerContext); + // [ADR-0131 D3/D4] Every position the security catalog holds — the + // environment registry, one namespace per deployment — never the + // per-organization `sys_position` rows: a position declared only in the + // registry is assignable, and a row with no definition grants nothing + // (the resolver reads the catalog), so it is not offered. const allPositions = async (): Promise => { - if (!ql?.find) return []; + const catalog = this.catalog(); + if (!catalog) return []; try { - const rows = await ql.find('sys_position', { - limit: 1000, - context: organizationScopedCtx(organizationId), - }); - return (Array.isArray(rows) ? rows : []) - .map((r: any) => String(r?.name ?? '')) + return (await catalog.list('position')) + .map((entry) => entry.name) .filter((n) => n && !ANCHOR_POSITIONS.has(n)); } catch { - return []; + return []; // a picker over an unreadable catalog offers nothing (fail closed) } }; @@ -530,7 +537,12 @@ export class DelegatedAdminGate { const assignablePositions: string[] = []; if (placing.length > 0) { for (const positionName of await allPositions()) { - const boundSets = await this.setsBoundToPosition(positionName, organizationId); + let boundSets: BoundSet[]; + try { + boundSets = await this.setsBoundToPosition(positionName); + } catch { + continue; // a position whose binding cannot be read is not offered + } const ok = placing.some((s) => boundSets.every( (bound) => @@ -675,12 +687,12 @@ export class DelegatedAdminGate { } // 5. The position must opt in to delegation. - if (!(await this.positionIsDelegatable(positionName, organizationId))) { + if (!(await this.positionIsDelegatable(positionName))) { deny(`position '${positionName}' is not delegatable — set delegatable: true on the position to allow it`, { position: positionName }); } // 6. A delegatable position must not distribute administration. - const boundSets = await this.setsBoundToPosition(positionName, organizationId); + const boundSets = await this.setsBoundToPosition(positionName); for (const b of boundSets) { if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) { deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); @@ -728,49 +740,23 @@ export class DelegatedAdminGate { })); } - /** - * Resolve a position NAME to the caller's own organization's `sys_position` - * row — the one read both position-keyed authority decisions below stand on. - * - * `sys_position` is a per-organization catalog upserted by - * `(name, organization_id)`, and `name` carries no installation-wide - * uniqueness, so under a walled posture two organizations may each hold a - * position of the same name. A `limit: 1` read under a bare system context - * answered whichever row the driver ordered first — an id ordering, not an - * organization — and that row then decided whether the position may be - * self-delegated and which permission sets it distributes. - * - * The same two arms {@link resolveSubtree} uses for a business-unit anchor, - * and for the same reasons: the read carries the caller's organization so - * the driver composes a tenant predicate, and {@link resolveOwnOrganizationRow} - * reduces what came back to the caller's OWN row (a driver's compatibility - * arm also returns organization-less rows; a driver with no tenant scoping - * returns every organization's). - * - * Fail closed: a name with no row in the caller's organization resolves to - * `null` — never to another organization's row, never to an unscoped read. - * An organization-less caller (`single` posture) keeps the by-name answer — - * there is no other organization for it to cross into. - */ - private async resolveOwnPosition(positionName: string, organizationId?: string): Promise { - const ql = this.deps.ql; - if (!ql?.find || !positionName) return null; - const rows = await ql.find('sys_position', { - where: { name: positionName }, - limit: ANCHOR_CANDIDATE_LIMIT, - context: organizationScopedCtx(organizationId), - }); - return resolveOwnOrganizationRow(Array.isArray(rows) ? rows : [], organizationId).own; + /** The security catalog bound to this gate's engine (ADR-0131 D3), or `undefined` when none was bound. */ + private catalog(): SecurityCatalogReader | undefined { + return securityCatalogReaderOf(this.deps.ql); } - /** [ADR-0091 D3 rule 5] Does the caller's OWN organization's row for this - * position opt in to delegation? A position with no row there is not - * delegatable (fail closed — see {@link resolveOwnPosition}). */ - private async positionIsDelegatable(positionName: string, organizationId?: string): Promise { + /** + * [ADR-0091 D3 rule 5] Does the position's DEFINITION opt in to delegation + * (`PositionSchema.delegatable`)? Read from the security catalog by name + * (ADR-0131 D3/D4) — the environment registry, one definition per name per + * deployment — never from a per-organization `sys_position` row. A name the + * catalog does not hold, an unbound or unreadable catalog: not delegatable + * (fail closed). + */ + private async positionIsDelegatable(positionName: string): Promise { try { - const pos = await this.resolveOwnPosition(positionName, organizationId); - const v = (pos as any)?.delegatable; - return v === true || v === 1 || v === '1'; + const entry = await this.catalog()?.resolve('position', positionName); + return entry?.definition.delegatable === true; } catch { return false; } @@ -787,7 +773,7 @@ export class DelegatedAdminGate { for (const t of targets) { const buId = t.next?.business_unit_id ?? null; const positionName = String(t.next?.position ?? t.prev?.position ?? ''); - const boundSets = positionName ? await this.setsBoundToPosition(positionName, organizationId) : []; + const boundSets = positionName ? await this.setsBoundToPosition(positionName) : []; const failure = this.firstApprovalFailure(held, (s) => { if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; @@ -1232,39 +1218,39 @@ export class DelegatedAdminGate { return [{ next: null, prev }]; } - /** The permission sets the caller's OWN organization's row for this position - * distributes. A position with no row there distributes nothing (fail - * closed — see {@link resolveOwnPosition}); the bindings and sets are then - * read by the resolved row's id, which no other organization shares. */ - private async setsBoundToPosition( - positionName: string, - organizationId?: string, - ): Promise> { - const ql = this.deps.ql; - if (!ql?.find) return []; - try { - const pos = await this.resolveOwnPosition(positionName, organizationId); - if (!pos?.id) return []; - const bindings = await ql.find('sys_position_permission_set', { - where: { position_id: pos.id }, - limit: 1000, - context: SYSTEM_CTX, - }); - const setIds = (Array.isArray(bindings) ? bindings : []) - .map((b: any) => b?.permission_set_id) - .filter(Boolean); - if (setIds.length === 0) return []; - const setRows = await ql.find('sys_permission_set', { - where: { id: { $in: setIds } }, - limit: setIds.length, - context: SYSTEM_CTX, - }); - return (Array.isArray(setRows) ? setRows : []) - .map((r: any) => ({ name: String(r?.name ?? ''), admin_scope: r?.admin_scope })) - .filter((r: any) => r.name); - } catch { - return []; + /** + * [ADR-0131 D3/D4] The permission sets a position distributes: the names its + * DEFINITION carries (`PositionSchema.permissionSets`), each with the + * `adminScope` of the catalog's definition of that name — the binding the + * authorization resolver grants through, read from the same security + * catalog, never from a `sys_position_permission_set` junction row (which + * grants nothing since the resolver stopped reading it). So the D12 + * containment and the assignable-positions listing judge exactly what an + * assignment will grant. + * + * A name the definition carries that the catalog does not hold is still + * returned, with no `adminScope`: it grants nothing today, but the binding is + * declared, and a set authored under that name later starts granting with no + * further write — so a delegate must have it allowlisted too (refuses more, + * never less). A position the catalog does not hold distributes nothing. + * + * ⛔ A catalog that cannot be read is not "distributes nothing": that would + * approve every assignment's allowlist check. The read's own loud failure + * (`AuthzStoreUnavailableError`) propagates, and the write is refused with it. + */ + private async setsBoundToPosition(positionName: string): Promise { + const catalog = this.catalog(); + if (!catalog || !positionName) return []; + const position = await catalog.resolve('position', positionName); + const names = Array.isArray(position?.definition.permissionSets) + ? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '') + : []; + const out: BoundSet[] = []; + for (const name of new Set(names)) { + const set = await catalog.resolve('permission', name); + out.push({ name, ...(set?.definition.adminScope ? { adminScope: set.definition.adminScope } : {}) }); } + return out; } /** @@ -1272,15 +1258,17 @@ export class DelegatedAdminGate { * * - **The grant's own set** — a delete, or an update that keeps the grant's * set: read BY NAME from the grant's `permission_set` (the pre-image's, or - * the name the update supplies), through {@link readGrantSetRows}. A grant - * that names nothing, or names a set with no catalog row it applies to, - * is not one a delegate can be shown to hold authority over, so the write - * is refused (`unresolved`) — fail closed; a tenant admin is not judged - * here at all. + * the name the update supplies), in the security catalog (ADR-0131 D3) — + * the definition the resolver grants. A grant that names nothing, or names + * a set the catalog does not hold, is not one a delegate can be shown to + * hold authority over, so the write is refused (`unresolved`) — fail + * closed; a tenant admin is not judged here at all. * - **The set the write points the grant AT** — an insert, or an update that - * re-points `permission_set_id`: the caller's own reference, read by that - * id as before. The name is not the caller's to supply (the platform - * derives it from that id after this gate), so there is no name to read. + * re-points `permission_set_id`: the caller's own reference. The name is + * not the caller's to supply (the platform derives it from that id after + * this gate), so the id is read to its row's NAME — the grant id lookup, + * which goes with the `permission_set_id` column — and the body judged is + * the catalog's definition of that name. */ private async directGrantSet( t: { next: any | null; prev: any | null }, @@ -1289,8 +1277,10 @@ export class DelegatedAdminGate { const repointed = !!t.next && (!t.prev || String(t.next[GRANT_SET_ID_FIELD] ?? '') !== String(t.prev[GRANT_SET_ID_FIELD] ?? '')); if (repointed) { - const setRow = await this.loadSetRowById(row[GRANT_SET_ID_FIELD]); - return { setRow, setName: String(setRow?.name ?? row[GRANT_SET_ID_FIELD] ?? '') }; + const idRow = await this.loadSetRowById(row[GRANT_SET_ID_FIELD]); + const idName = typeof idRow?.name === 'string' && idRow.name !== '' ? idRow.name : undefined; + const setRow = idName ? await this.catalogSet(idName) : null; + return { setRow, setName: idName ?? String(row[GRANT_SET_ID_FIELD] ?? '') }; } const name = grantSetNameOf(row); if (!name) { @@ -1300,23 +1290,28 @@ export class DelegatedAdminGate { unresolved: 'the grant names no permission set (it is not yet named, or could not be) — only a tenant admin may change it', }; } - const setRow = await this.loadSetRowForGrant(row); + const setRow = await this.catalogSet(name); if (!setRow) { return { setRow: null, setName: name, - unresolved: `permission set '${name}' has no catalog row this grant applies to — only a tenant admin may change it`, + unresolved: `permission set '${name}' is not in the security catalog, so this grant confers nothing — only a tenant admin may change it`, }; } - return { setRow, setName: String(setRow.name ?? name) }; + return { setRow, setName: name }; } - /** The catalog row a stored grant names, by its name (`null` when none applies or the read fails). */ - private async loadSetRowForGrant(grant: any): Promise { - if (!this.deps.ql?.find) return null; + /** + * The security catalog's definition of the permission set `name`, as the + * containment check reads it (`{ name, adminScope }`), or `null` when the + * catalog holds no such set, none is bound, or the read fails (fail closed: + * the caller refuses a delegate's write it cannot judge). + */ + private async catalogSet(name: string): Promise { try { - const setRowOf = await readGrantSetRows(this.deps.ql, [grant]); - return setRowOf(grant) ?? null; + const entry = await this.catalog()?.resolve('permission', name); + if (!entry) return null; + return { name: entry.name, ...(entry.definition.adminScope ? { adminScope: entry.definition.adminScope } : {}) }; } catch { return null; } diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index 0fda95893b0..e47e483edc3 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#15598] What `ObjectQL.find` actually resolves at the seven seams that used + * [#15598] What `ObjectQL.find` actually resolves at the seams that used * to normalize it — one pin per seam, each driven against a REAL engine. * * ## The class, and why a type was not allowed to settle it @@ -17,7 +17,7 @@ * Every seam answered `[object Array]`, with no own `records` key, on a * populated page and an empty one alike. * - * ⚠️ These are seven pins, not one pin repeated. A single reading of "the engine + * ⚠️ These are six pins, not one pin repeated. A single reading of "the engine * returns an array" would say nothing about whether a given seam is even * reachable — six of them sit behind a trigger (a truncated page, a refused * bulk write, a publish-materializer call path), and a seam nothing reaches is @@ -25,15 +25,12 @@ * through the real function and asserts the answer that block produced, and * {@link assertBareArrayPage} then reads back the value the engine handed it. * - * ## The seventh is the opposite defect, and its legs say so - * - * `security-plugin.ts`'s `sys_permission_set` loader was not merely carrying a - * dead limb: it swallowed a THROWN read into `[]` and mapped an unreadable - * result to `[]` as well, so three different facts left as one value. On the - * enforcement plane that is a silent withdrawal of grants that exist. Its legs - * below pin the repaired direction — the fault PROPAGATES, the refusal carries - * an envelope, and `PermissionEvaluator`'s #2565 warn (unreachable while the - * loader swallowed) fires while the request stays fail-closed. + * The seventh seam, `security-plugin.ts`'s `sys_permission_set` loader, is + * gone: the plugin takes a set's body from the security catalog + * (ADR-0131 D3/D4) and reads no `sys_permission_set` row, so it has no + * engine page to pin. Its fault direction (a read that did not answer + * propagates, and `PermissionEvaluator`'s #2565 warn fires) is pinned on the + * catalog loader in `security-readers-catalog.test.ts`. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -58,8 +55,6 @@ import { upsertPackagePermissionSet } from './bootstrap-declared-permissions.js' import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; import { normalizeManagedByVocab } from './normalize-managed-by.js'; -import { SecurityPlugin } from './security-plugin.js'; -import { PermissionEvaluator } from './permission-evaluator.js'; const SYS = { context: { isSystem: true } } as any; const ORG = 'org_a'; @@ -342,124 +337,3 @@ describe('[#15598] block 6 — normalize-managed-by tryFind', () => { expect(rows[0].managed_by).toBe('platform'); }, 120_000); }); - -describe('[#15598] block 7 — security-plugin sys_permission_set loader (the DROP shape)', () => { - /** Boot the real plugin against `engine`, and hand back its private loader. */ - async function loaderOver(engine: any, seen: Seen, findOverride?: (o: string, q?: any, opt?: any) => Promise) { - const plugin = new SecurityPlugin(); - const svc = observed(engine, seen, findOverride ? { find: findOverride } : {}); - const { warns, logger } = recordingLogger(); - const ctx: any = { - logger, - registerService: () => { /* noop */ }, - registerMiddleware: () => { /* noop */ }, - getService: (n: string) => { - if (n === 'objectql') return svc; - if (n === 'metadata') return { list: async () => [] }; - if (n === 'manifest') return { register: () => { /* noop */ } }; - return undefined; - }, - }; - await plugin.init(ctx); - await plugin.start(ctx); - const loader = (plugin as any).dbLoaderFor?.(ORG); - expect(typeof loader, 'the loader was never built — the boot bailed out').toBe('function'); - return { loader: loader as (names: string[]) => Promise, warns }; - } - - it('loads the DB-authored set out of one bare array', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_permission_set', - { name: 'ps_db', label: 'DB authored', managed_by: 'admin', organization_id: ORG, active: true }, - SYS, - ); - const seen: Seen = []; - const { loader } = await loaderOver(engine, seen); - seen.length = 0; // isolate the loader's OWN read - - const sets = await loader(['ps_db']); - - assertBareArrayPage(seen, 'dbLoaderFor'); - expect(sets.map((s: any) => s.name)).toEqual(['ps_db']); - }, 120_000); - - it('PROPAGATES a thrown read instead of swallowing it into "no permission sets"', async () => { - const engine = await boot(); - const seen: Seen = []; - const outage = Object.assign(new Error("Datasource 'primary' is declared but not connected"), { - code: 'ERR_DATASOURCE_UNAVAILABLE', - }); - const { loader } = await loaderOver(engine, seen, async (o: string, q?: any, opt?: any) => { - if (o === 'sys_permission_set' && q?.where?.name?.$in) throw outage; - return engine.find(o, q, opt); - }); - - // The whole repair: this used to resolve `[]`. An outage and an empty - // catalog are different facts and must not leave by the same door. - await expect(loader(['ps_db'])).rejects.toBe(outage); - }, 120_000); - - it('REFUSES a non-array result with an envelope, rather than inventing an empty page', async () => { - const engine = await boot(); - const seen: Seen = []; - const { loader } = await loaderOver(engine, seen, async (o: string, q?: any, opt?: any) => { - // The #13706 shape, planted deliberately: a `find()` that resolves an - // ENVELOPE. It is what the removed limb claimed to handle, and the point - // of the repair is that it is refused rather than silently normalized. - if (o === 'sys_permission_set' && q?.where?.name?.$in) return { records: [{ name: 'ps_db' }] } as any; - return engine.find(o, q, opt); - }); - - // ⛔ Not `toThrow()` alone — the envelope is the assertion. A bare "it threw" - // passes against any accident on this path. - await expect(loader(['ps_db'])).rejects.toMatchObject({ - code: 'DATABASE_ERROR', - status: 500, - name: 'PermissionSetReadUnansweredError', - }); - }, 120_000); - - it('REFUSES a page carrying a non-row, which the trailing filter used to drop in silence', async () => { - const engine = await boot(); - const seen: Seen = []; - const { loader } = await loaderOver(engine, seen, async (o: string, q?: any, opt?: any) => { - if (o === 'sys_permission_set' && q?.where?.name?.$in) return ['ps_db'] as any; - return engine.find(o, q, opt); - }); - - await expect(loader(['ps_db'])).rejects.toMatchObject({ - code: 'DATABASE_ERROR', - status: 500, - name: 'PermissionSetReadUnansweredError', - }); - }, 120_000); - - it('stays FAIL-CLOSED through the evaluator, and re-arms the #2565 warn the swallow had made unreachable', async () => { - const engine = await boot(); - const seen: Seen = []; - const outage = Object.assign(new Error("Datasource 'primary' is declared but not connected"), { - code: 'ERR_DATASOURCE_UNAVAILABLE', - }); - const { loader } = await loaderOver(engine, seen, async (o: string, q?: any, opt?: any) => { - if (o === 'sys_permission_set' && q?.where?.name?.$in) throw outage; - return engine.find(o, q, opt); - }); - - const warns: Array<{ msg: string; meta: any }> = []; - const resolved = await new PermissionEvaluator().resolvePermissionSets( - ['ps_db'], - { list: async () => [] }, - [], - loader, - { logger: { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); } } }, - ); - - // Enforcement is UNCHANGED — the unresolved set still grants nothing. - expect(resolved).toEqual([]); - // What changed is that the loss is now sayable. While the loader swallowed - // its own read failure this warn could never fire, so a DB outage and an - // empty catalog produced identical, undiagnosable 403s. - expect(warns.map((w) => w.msg).join('\n')).toContain('db lookup failed'); - }, 120_000); -}); diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index a1ff1f2badd..2f5acfb507b 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -40,7 +40,6 @@ const FLOOR = [ 'DetailRecordNotFoundError', 'MasterReferenceMissingError', 'MaskedValueWriteError', - 'PermissionSetReadUnansweredError', 'ExplainObjectNotFoundError', 'PermissionSetNameConflictError', ]; diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index 99d282ed9fd..7195b026c41 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -263,61 +263,6 @@ export class MaskedValueWriteError extends Error { } } -/** - * The DB-authored permission-set read did not answer → `500 DATABASE_ERROR`. - * - * ## Why this exists at all — a read that did not happen is not an empty answer - * - * `SecurityPlugin`'s `sys_permission_set` loader is the ENFORCEMENT plane's door - * to DB-authored sets. It used to map three different facts onto one output, the - * empty list: the read succeeded and the catalog is empty; the read THREW; the - * read resolved something the loader could not read. The last two are invented - * answers, and the invention is not benign — "this principal has no permission - * sets" silently withdraws grants that exist, while every request keeps looking - * completely normal. That is the failure the loader's own neighbouring comment - * calls out as the thing not to do: dropping a row "revokes standing access with - * no signal at the moment of loss". - * - * ## Why REFUSING is the right direction, and not this seam's own invention - * - * The consumer already declares the handling. `PermissionEvaluator - * .resolvePermissionSets` catches a throwing loader, keeps the request - * fail-closed (the unresolved sets grant nothing — unchanged), and NAMES the - * failure in a warn, because "without the warn, a transient DB error makes - * custom permission sets silently vanish and the resulting 403s are - * undiagnosable" (#2565). A loader that swallowed its own read failure made - * that warn unreachable: the diagnostic the repo had already built could never - * fire. Throwing restores it. It also matches the maintainer's 2026-08-11 - * store-fault ruling for this plugin — FAIL-CLOSED, and a fault PROPAGATES; - * absent means absent and only absent. - * - * ⚠️ Enforcement is unchanged in both directions: before and after, a read that - * did not answer grants nothing. What changes is that it is now DISTINGUISHABLE - * from an empty catalog. - * - * `DATABASE_ERROR` comes from ADR-0112's closed vocabulary rather than a new - * spelling — the condition is exactly "a database operation did not answer". - * The status is declared for consistency with this file's other classes, not - * because the error is expected to reach a transport: its only consumer catches - * it one frame up. - */ -export class PermissionSetReadUnansweredError extends Error { - readonly code = 'DATABASE_ERROR'; - readonly status = 500; - readonly statusCode = 500; - /** The set names the unanswered read was asked for. */ - readonly names: readonly string[]; - constructor(names: readonly string[], detail: string) { - super( - `[Security] The sys_permission_set read for [${names.join(', ')}] did not answer: ${detail}. ` - + `Refusing rather than reporting an empty catalog — an unanswered read reported as "no permission ` - + `sets" silently withdraws grants that exist while every request still looks normal.`, - ); - this.name = 'PermissionSetReadUnansweredError'; - this.names = [...names]; - } -} - /** * [#18253] `explain` was asked about an object name that no declaration on this * runtime answers to → `404 OBJECT_NOT_FOUND`. diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 1d8f2ff143a..3e958635bfb 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -22,9 +22,10 @@ import { isGrantActive, isGrantExpired, - isRowActive, derivePosture as deriveAdminPosture, + readDisabledCatalogNames, resolveUserAuthzGrants, + securityCatalogReaderOf, } from '@objectstack/core'; import { crossFieldClassRefusalCarriedBy, @@ -66,7 +67,7 @@ import { unresolvedPostureExplainDetail, type UnresolvedPostureCause, } from './unresolved-posture.js'; -import { grantSetNameOf, readGrantSetRows } from './grant-permission-set-name.js'; +import { grantSetNameOf } from './grant-permission-set-name.js'; const SYSTEM_CTX = { isSystem: true } as const; @@ -450,7 +451,7 @@ function untilOfGrantRow(r: any): string | undefined { * `contributors[].state`): a grant row the principal HOLDS that the resolver * fail-closed DROPPED, with the closed reason enumeration naming why — * `expired` (ADR-0091 D2 validity window) or `deactivated` (ADR-0049 / #8613 - * catalogue `active` switch). `until` is the window bound, present only for + * switch, read from the `sys_metadata_activation` ledger). `until` is the window bound, present only for * the expired reason. One array, one reason discriminant — deliberately NOT a * sibling array per cause (maintainer ruling 2026-08-18). */ @@ -477,20 +478,25 @@ export interface DroppedGrant { * "held until … — expired" instead of silently omitting a grant the admin * knows they granted. This is "why did access DISAPPEAR", and only a dropped * row can answer it. - * - **deactivated** (commit 42b05af89) — a row whose CATALOGUE entry - * (`sys_permission_set.active` / `sys_position.active`) is switched off - * (ADR-0049 / #8613), so the grant stopped resolving for everyone holding - * it. Deactivation is an incident-response control with no date on the + * - **deactivated** (commit 42b05af89) — a row whose position or permission + * set is switched off (ADR-0049 / #8613), so the grant stopped resolving + * for everyone holding it. The switch is the activation ledger's + * (`sys_metadata_activation`, ADR-0126 §3 regime C as ADR-0131 D6 amends + * it), asked through the resolver's own read (`readDisabledCatalogNames`), + * so explain and enforce cannot disagree; a catalog row's `active` column + * is not read. Deactivation is an incident-response control with no date on the * user's own grant row, so without this state the set is simply ABSENT and * explain answers like the grant never existed — the exact silence the * contributor-attribution feature exists to prevent. * - **delegated** — the `delegated_from` provenance of a row that DID resolve, * so a position can be attributed "via delegation from X, until Y". * - * All verdicts come from the SAME shared predicate modules the resolver uses - * (`isGrantActive` / `isGrantExpired` for the ADR-0091 window, `isRowActive` - * for the ADR-0049 switch — all `@objectstack/core`) — one implementation of - * each rule, consulted twice, never re-derived here. + * All verdicts come from the SAME shared reads the resolver uses + * (`isGrantActive` / `isGrantExpired` for the ADR-0091 window, + * `readDisabledCatalogNames` for the ADR-0049 switch, the security catalog + * (`securityCatalogReaderOf`) for whether a set exists — all + * `@objectstack/core`) — one implementation of each rule, consulted twice, + * never re-derived here. * * A row that is BOTH expired and deactivated reports `expired`: the window * verdict is judged on the user's own grant row, the same order the resolver @@ -500,9 +506,8 @@ export interface DroppedGrant { * re-read for the grants this pass already walks — `sys_user_position` rows * and direct `sys_user_permission_set` grants. A deactivated position held via * role projection, or a deactivated SET reached only through an active - * position's `sys_position_permission_set` linkage, is not re-derived here — - * that would replicate the resolver's aggregation, which is the design this - * pass exists to avoid. + * position's `permissionSets`, is not re-derived here — that would replicate + * the resolver's aggregation, which is the design this pass exists to avoid. */ async function collectGrantProvenance( ql: any, @@ -535,21 +540,14 @@ async function collectGrantProvenance( droppedGrants.push({ kind: 'position', name: p, state: 'expired', until: untilOfGrantRow(r) }); } } - // [commit 42b05af89 / ADR-0049] A held position whose `sys_position` catalogue row is - // explicitly deactivated was dropped by the resolver (step 6a) — report it - // instead of letting it vanish. A name with no row has no flag to read and - // is untouched, matching the resolver. + // [commit 42b05af89 / ADR-0049] A held position the activation ledger + // switched off was dropped by the resolver (step 6a) — report it instead of + // letting it vanish. The resolver's own ledger read, so a name with no + // ledger row is in effect here exactly as it is there. if (heldPositionNames.size > 0) { - const posRows = await ql.find('sys_position', { - where: { name: { $in: Array.from(heldPositionNames) } }, - limit: heldPositionNames.size, - context: SYSTEM_CTX, - }); - for (const row of Array.isArray(posRows) ? posRows : []) { - const n = String((row as any)?.name ?? ''); - if (n && heldPositionNames.has(n) && !isRowActive(row)) { - droppedGrants.push({ kind: 'position', name: n, state: 'deactivated' }); - } + const disabled = await readDisabledCatalogNames(ql, Array.from(heldPositionNames), []); + for (const n of heldPositionNames) { + if (disabled.positions.has(n)) droppedGrants.push({ kind: 'position', name: n, state: 'deactivated' }); } } } catch { /* table unavailable → no provenance to report */ } @@ -564,27 +562,30 @@ async function collectGrantProvenance( // deactivated — the one remaining reason the resolver drops them (step 6b). const activeRows = grantRows.filter((g: any) => isGrantActive(g, nowMs)); if (expiredRows.length > 0 || activeRows.length > 0) { - // [commit 42b05af89] One `sys_permission_set` read serves both reasons: - // the set row an expired grant names, and the ADR-0049 `active` flag of - // the one a held grant names. [ADR-0131 D4] Read by the grant's NAME — - // its own organization's row, else the organization-less one - // ({@link readGrantSetRows}); deactivation is still the row's flag. - const setRowOf = await readGrantSetRows(ql, [...expiredRows, ...activeRows]); - for (const g of expiredRows) { - const s = setRowOf(g); - const n = (s as any)?.name; - if (n) droppedGrants.push({ kind: 'permission_set', name: String(n), state: 'expired', until: untilOfGrantRow(g) }); + // [ADR-0131 D3/D4] A grant's set is the security catalog's definition of + // the name it carries — the one the resolver grants (step 6). A name the + // catalog does not hold resolves nothing either way, so it is reported + // neither as expired nor as deactivated: there is no set to lose. + const catalog = securityCatalogReaderOf(ql); + const held = new Set(); + for (const g of [...expiredRows, ...activeRows]) { + const n = grantSetNameOf(g) as string; + if (!held.has(n) && catalog && (await catalog.resolve('permission', n))) held.add(n); } - const deactivatedNames = new Set(); - for (const g of activeRows) { - const s = setRowOf(g); - const n = (s as any)?.name; - // Dedupe: several grant rows can point at one deactivated set; the - // catalogue-row fact is reported once. - if (n && !isRowActive(s)) deactivatedNames.add(String(n)); + for (const g of expiredRows) { + const n = grantSetNameOf(g) as string; + if (held.has(n)) droppedGrants.push({ kind: 'permission_set', name: n, state: 'expired', until: untilOfGrantRow(g) }); } - for (const n of deactivatedNames) { - droppedGrants.push({ kind: 'permission_set', name: n, state: 'deactivated' }); + // [commit 42b05af89 / ADR-0049] Whether a held set is switched off is the + // activation ledger's answer, read once for every name a window-active + // grant carries — the resolver's own read. Several grant rows can name + // one deactivated set; the fact is reported once. + const activeNames = [...new Set(activeRows.map((g: any) => grantSetNameOf(g) as string))].filter((n) => held.has(n)); + if (activeNames.length > 0) { + const disabled = await readDisabledCatalogNames(ql, [], activeNames); + for (const n of activeNames) { + if (disabled.permissions.has(n)) droppedGrants.push({ kind: 'permission_set', name: n, state: 'deactivated' }); + } } } } catch { /* table unavailable → no provenance to report */ } diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.ts index 92f5addcf1c..1ffa2986d5f 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.ts @@ -1,64 +1,57 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `sys_user_position.position` must name a `sys_position` catalog row — the - * write-path refusal for an assignment that names nothing. + * `sys_user_position.position` must name a position the security catalog + * holds — the write-path refusal for an assignment that names nothing. * * ## The hole * - * `position` is declared `Field.text` and references `sys_position.name` by - * convention only, while every sibling reference column on the same row - * (`user_id`, `organization_id`, `business_unit_id`, `granted_by`, - * `delegated_from`) is a `Field.lookup` the engine already refuses when it - * names no row (`reference_not_found`, `assertReferencesResolve` in - * `@objectstack/objectql`). So a value naming no catalog row was stored and - * answered `201`, and then resolved to nothing: the runtime resolver joins - * `sys_position` BY NAME, so the assignment granted no permission set, reached - * no sharing rule and put nothing in `current_user.positions` that any reader - * could act on. The holder signed in to an app that reads nothing, and no layer - * said why. The measured spelling is the position's record ID written where its - * NAME belongs — the neighbouring `user_id` is an id, and the analogous + * `position` is declared `Field.text` and references a position by NAME, while + * every sibling reference column on the same row (`user_id`, `organization_id`, + * `business_unit_id`, `granted_by`, `delegated_from`) is a `Field.lookup` the + * engine already refuses when it names no row (`reference_not_found`, + * `assertReferencesResolve` in `@objectstack/objectql`). So a value naming no + * position was stored and answered `201`, and then resolved to nothing: the + * assignment granted no permission set, reached no sharing rule and put + * nothing in `current_user.positions` that any reader could act on. The holder + * signed in to an app that reads nothing, and no layer said why. The measured + * spelling is the position's record ID written where its NAME belongs — the + * neighbouring `user_id` is an id, and the analogous * `sys_user_permission_set.permission_set_id` is genuinely id-typed, so the id * is what an author reaches for by analogy. * - * ## The predicate — exactly "no catalog row carries this name" + * ## The predicate — exactly "the name resolves to no position" * * Ruled on the card that filed the hole (#16712, maintainer-confirmed), * verbatim: «⛔ The predicate is exactly "no catalog row carries this name" — * never "this assignment cannot take effect": ADR-0049 keeps a deactivated - * position's assignment while it stops granting». So a DEACTIVATED position - * (`active: false`) is still a catalog row, and an assignment naming it is - * accepted — it stops granting, it is not refused. + * position's assignment while it stops granting». So a DEACTIVATED position is + * still a position, and an assignment naming it is accepted — it stops + * granting, it is not refused. (The switch is the activation ledger's, and + * this refusal does not read it.) * - * ## Whose catalog — the writer's organization plus organization-less rows + * ## Whose catalog — the security catalog, one per deployment * - * The ruling fixes the predicate; #20297 fixes the catalog it reads, by the - * platform's standing tenancy rule rather than by a new one. Every catalog read - * here runs under {@link catalogReadContext} — `{ ...context, isSystem: true }`, - * the `sudo()`-shaped elevation of the engine's own lookup probe - * (`assertReferencesResolve` in `@objectstack/objectql`, #19808), never a bare - * `{ isSystem: true }`: + * [ADR-0131 D3/D4] The catalog is the environment registry: positions + * declared by a package, the platform's built-ins, and every position an + * environment author saved through the metadata door, read BY NAME through + * the reader the security plugin binds to its engine + * (`securityCatalogReaderOf`). It is the one source the authorization + * resolver grants a held position through, so this refusal accepts exactly + * the names an assignment can take effect under, and refuses exactly the + * names that resolve nowhere: * - * - the elevation is about VISIBILITY: existence is a fact about the database, - * not about the writer's row-level reach, so RBAC, RLS and FLS are bypassed; - * - it is never about TENANCY: the writer's context is spread first, so the - * engine forwards its `tenantId` to the driver, which reads the writer's - * organization plus organization-less rows (`organization_id IS NULL`). + * - a position declared only in the registry (no `sys_position` row) is + * ACCEPTED — before the cutover the per-organization row table was the + * catalog read here, and such an assignment was refused `400`; + * - a name only a `sys_position` row carries (an organization's Setup row + * under a wall with no definition, a row whose definition was never saved) + * is REFUSED: the resolver grants nothing through it (#15196 Q3 = A). * - * So a name only ANOTHER organization's catalog carries is refused exactly like - * a name no organization carries — the same envelope and the same message — - * and the accept-or-refuse answer tells an organization admin nothing about any - * other organization's catalog. The bare spelling read every organization, - * which accepted such a name (it resolves nothing in the writer's organization) - * and answered "some other organization has this position" apart from "no one - * has it": the cross-tenant existence oracle the engine probe (#19808) and the - * delegated-admin gate's position-name reads (#19819, #19860) were closed - * against. Under a `single` posture the declared catalog is seeded with no - * organization, so when the deployment holds one organization every writer - * reads the whole catalog; a `single` deployment holding several (reported at - * `error` at boot, #17010) gives each writer its active organization's - * positions plus the organization-less ones. A writer whose context names no - * organization reads every organization's rows, as the engine probe does. + * The catalog is environment-level and takes no organization (ADR-0131 D3), + * so every writer reads the same catalog and the verdict says nothing about + * any organization's own data — there is no per-organization catalog left to + * leak the existence of. * * ## Which writes it judges * @@ -89,10 +82,11 @@ * write stores it with `201` (measured over SQLite: `123`, `true`, `{}`, * `{ a: 1 }` and `['x']` all stored). So those are judged by their string * form — a scalar by `String(value)`, an object or array by its JSON text — - * and refused like any name no catalog row carries. The stored text is the - * driver's, not that string form (SQLite stores `123` as `'123.0'`), so a - * non-string whose string form happens to equal a catalog name is accepted - * and resolves nothing: the engine's `text` leniency, outside this refusal; + * and refused like any name that resolves to no position. The stored text is + * the driver's, not that string form (SQLite stores `123` as `'123.0'`), so + * a non-string whose string form happens to equal a position name is + * accepted and resolves nothing: the engine's `text` leniency, outside this + * refusal; * - an update the engine refuses on its own dispatch predicate. * * ## Where it runs @@ -101,8 +95,7 @@ * INSIDE it: the delegated-admin gate and the object CRUD check have both * passed before the catalog is consulted. A caller who may not write this * table is refused `403` on authority, identically whatever the value names, - * and never sees the catalog verdict. The tenant boundary is the catalog - * read's own (above), not this placement's. + * and never sees the catalog verdict. * * The placement is also what lets the by-id PRE-IMAGE read ({@link SYSTEM_CTX}) * stay bare: that read happens only after the security middleware has admitted @@ -120,22 +113,22 @@ * code is the registered ADR-0112 `VALIDATION_FAILED` (built by * `validationFailure`, the shared constructor both HTTP doors map to 400), and * the field-level code is a member of the closed ADR-0114 catalog. The message - * names the value, says the column takes the catalog NAME, and names the fix — - * the name itself when the value is the record id of a position the writer's - * catalog holds (read the same way as the check, so never another - * organization's). + * names the value, says the column takes the position's NAME, and names the + * fix — the name itself when the value is the record id of a `sys_position` + * row the writer's organization holds and the catalog resolves that row's + * name (the id is a row fact, read under {@link catalogReadContext}, so never + * another organization's). * * ## Fails open * - * A catalog that cannot be read (the object is not registered in this - * composition, or the read throws) refuses nothing — an integrity check that - * cannot run must not invent a rejection, the stance the engine's own lookup - * probe takes. A failed read is reported at `warn`: the write proceeds, and the - * operator is told the check did not run. A name the query layer would read as - * a filter placeholder (`{…}`) is not a failed read: it is compared literally - * (`catalogCarries`), so its verdict never falls open. + * A catalog that cannot be read (no reader is bound to this engine, or the + * read throws) refuses nothing — an integrity check that cannot run must not + * invent a rejection, the stance the engine's own lookup probe takes. A failed + * read is reported at `warn`: the write proceeds, and the operator is told the + * check did not run. */ +import { securityCatalogReaderOf } from '@objectstack/core'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core'; import type { FieldErrorCode } from '@objectstack/spec/api'; import { @@ -149,8 +142,10 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; /** The assignment object this refusal is registered on. */ export const POSITION_ASSIGNMENT_OBJECT = 'sys_user_position'; -/** The catalog a `position` value must name a row of. */ +/** The row table the id-spelling hint reads a record id in — never the verdict. */ export const POSITION_CATALOG_OBJECT = 'sys_position'; +/** The security catalog type a `position` value must name an item of. */ +const POSITION_CATALOG_TYPE = 'position'; /** The column judged, on {@link POSITION_ASSIGNMENT_OBJECT}. */ export const POSITION_FIELD = 'position'; @@ -158,18 +153,18 @@ export const POSITION_FIELD = 'position'; * The by-id PRE-IMAGE read only (is the stored `position` being changed?) — * never a catalog read. Bare, because the security middleware has already * refused, identically, every row id the writer cannot update (see "Where it - * runs"); every catalog read goes through {@link catalogReadContext}. + * runs"); the id-spelling hint's row read goes through {@link catalogReadContext}. */ const SYSTEM_CTX = { isSystem: true } as const; /** - * The context every `sys_position` read runs under: the writer's own context - * with `isSystem` set — the engine lookup probe's `sudo()`-shaped spelling. The - * spread carries the writer's `tenantId`, so the read sees the writer's - * organization plus organization-less rows; a context naming no organization - * reads every organization. ⛔ Never a bare `{ isSystem: true }` here: that - * spans every organization and makes the refusal a cross-tenant existence - * oracle (module note, "Whose catalog"). + * The context the id-spelling hint's `sys_position` read runs under: the + * writer's own context with `isSystem` set — the engine lookup probe's + * `sudo()`-shaped spelling. The spread carries the writer's `tenantId`, so the + * read sees the writer's organization plus organization-less rows; a context + * naming no organization reads every organization. ⛔ Never a bare + * `{ isSystem: true }` here: that spans every organization, and a hint naming + * another organization's row would be a cross-tenant existence oracle. */ function catalogReadContext(context: unknown): Record { const own = context && typeof context === 'object' ? (context as Record) : {}; @@ -342,41 +337,34 @@ export async function writtenPositionNames(ql: any, opCtx: any): Promise { const { ql, logger } = deps; if (names.length === 0) return []; - if (!ql || typeof ql.find !== 'function') return null; - // An unregistered catalog is a composition without one — nothing to judge - // against, the same silent stand-down the engine's own lookup probe takes. - if (typeof ql.getSchema === 'function' && !ql.getSchema(POSITION_CATALOG_OBJECT)) return null; + const catalog = securityCatalogReaderOf(ql); + // No catalog bound — nothing to judge against, the same silent stand-down + // the engine's own lookup probe takes for an unregistered target. + if (!catalog) return null; - const readCtx = catalogReadContext(context); const missing: string[] = []; for (const name of names) { let carried: boolean; try { - carried = await catalogCarries(ql, name, readCtx); + carried = (await catalog.resolve(POSITION_CATALOG_TYPE, name)) !== undefined; } catch (e) { logger?.warn?.( - `[security] the ${POSITION_CATALOG_OBJECT} catalog could not be read, so a ` + - `${POSITION_ASSIGNMENT_OBJECT} write was NOT checked for a position name that resolves ` + - `to no catalog row — it proceeds unchecked`, + `[security] the security catalog could not be read, so a ${POSITION_ASSIGNMENT_OBJECT} write was NOT ` + + 'checked for a position name that resolves to no position — it proceeds unchecked', { object: POSITION_ASSIGNMENT_OBJECT, error: (e as Error)?.message ?? String(e) }, ); return null; @@ -387,34 +375,12 @@ export async function namesWithoutCatalogRow( } /** - * Does the catalog, read under `readCtx`, hold a row whose `name` is exactly - * `name`? A name the query layer would read as a filter PLACEHOLDER (a - * fully-wrapped `{…}`, recognised by the spec's `classifyFilterToken`, the - * predicate `@objectstack/core`'s `resolveFilterTokens` applies to every `where` - * comparand) cannot go in `where` as itself: an unknown token throws - * `FILTER_TOKEN_UNKNOWN` and a known one is replaced by its value, so the read - * would fail open or judge a different name. Such a name is compared here - * instead, against the catalog names that share its first character. - */ -async function catalogCarries(ql: any, name: string, readCtx: Record): Promise { - if (classifyFilterToken(name) === null) { - const rows = await ql.find(POSITION_CATALOG_OBJECT, { where: { name }, limit: 1, context: readCtx }); - return Array.isArray(rows) && rows.length > 0; - } - const rows = await ql.find(POSITION_CATALOG_OBJECT, { - where: { name: { $startsWith: name.charAt(0) } }, - fields: ['name'], - context: readCtx, - }); - return Array.isArray(rows) && rows.some((row: any) => row?.name === name); -} - -/** - * For each missing value that is the record ID of a position the writer's - * catalog holds, that position's NAME — the exact fix. Read under the same - * {@link catalogReadContext} as the check itself, so the hint can name only a - * position the check could have accepted, never another organization's. A read - * that fails yields no hint. + * For each missing value that is the record ID of a `sys_position` row the + * writer's organization holds, that row's NAME — the exact fix, offered only + * when the security catalog resolves that name, so the hint never names a + * position the check would refuse. The id is a row fact, read under + * {@link catalogReadContext} (never another organization's row). A read that + * fails yields no hint. */ export async function idSpellingHints( deps: PositionCatalogRefusalDeps, @@ -424,16 +390,18 @@ export async function idSpellingHints( const hints = new Map(); const { ql } = deps; if (!ql || typeof ql.find !== 'function') return hints; + const catalog = securityCatalogReaderOf(ql); + if (!catalog) return hints; const readCtx = catalogReadContext(context); for (const value of values) { // A placeholder-shaped value is never a record id, and in `where` it would - // be resolved as a filter token rather than compared (see catalogCarries). + // be resolved as a filter token rather than compared. if (classifyFilterToken(value) !== null) continue; try { const rows = await ql.find(POSITION_CATALOG_OBJECT, { where: { id: value }, limit: 1, context: readCtx }); const row = Array.isArray(rows) ? rows[0] : null; const name = row && typeof row.name === 'string' && row.name !== '' ? row.name : null; - if (!name) continue; + if (!name || !(await catalog.resolve(POSITION_CATALOG_TYPE, name))) continue; hints.set(value, name); } catch { // No hint — the refusal still stands and still names the column's contract. @@ -447,14 +415,15 @@ export function positionNotInCatalogMessage(value: string, idOfPosition?: string if (idOfPosition) { return ( `${POSITION_LABEL}: no position is named '${value}' — that is the record id of the position ` + - `'${idOfPosition}'. ${POSITION_ASSIGNMENT_OBJECT}.${POSITION_FIELD} takes the position's machine name ` + - `(${POSITION_CATALOG_OBJECT}.name), never its id: write '${idOfPosition}'.` + `'${idOfPosition}'. ${POSITION_ASSIGNMENT_OBJECT}.${POSITION_FIELD} takes the position's machine name, ` + + `never its id: write '${idOfPosition}'.` ); } return ( `${POSITION_LABEL}: no position is named '${value}'. ${POSITION_ASSIGNMENT_OBJECT}.${POSITION_FIELD} ` + - `takes the machine name of a position in the catalog (${POSITION_CATALOG_OBJECT}.name), never its record ` + - `id: write the name of an existing position, or create the position first.` + `takes the machine name of a position in the security catalog (a package's position, a built-in, or one ` + + `saved through the metadata door), never its record id: write the name of an existing position, or ` + + `declare the position first.` ); } diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index ac350354b74..f65d45d8d06 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -67,6 +67,28 @@ * `packagedBaseRefusal`) keeps the stand-down below, as the `/automation` * doors keep theirs. * + * ## One namespace — a name the environment catalog already holds is not taken either (ruling Q (a)) + * + * Under `single` a position name is unique per DEPLOYMENT (the cutover's + * ruling Q (a): "the write door refuses a second holder"), while the row's own + * index is unique per organization only. So a Setup **create**, or a **rename + * into** a name, that the security catalog already resolves to a definition + * no package or built-in holds (S10 above answers those) — an environment + * definition another organization's row wrote, one an author saved through + * the metadata door, or one an application stack declares in memory — is + * refused, and the row write is undone. Before this, the definition save that + * follows overwrote the existing definition with this row's label and + * description and dropped its `permissionSets`: two holders silently merged + * into one. The refusal is the engine's own unique-violation envelope + * (`DuplicateRecordError`, `409 UNIQUE_VIOLATION` over HTTP, on `name`) — the + * answer a second row of the same name in one organization already gets — so + * an author meets one dialect for "this name is taken". The read is the + * catalog the resolver grants through (`securityCatalogReaderOf`), asked + * before any definition is saved; a catalog that cannot be read refuses (the + * row goes and that failure is the answer), because accepting would merge + * blind. The upgrade ceremony reports a pair that already exists as + * `conflicting` and never merges it (`convertPositionBindingRows`). + * * ## Where it stands down — the write proceeds exactly as before * * - **a system write** (`isSystem`): the seeders and the package door write @@ -100,6 +122,8 @@ * because the row is still the row the data door wrote. */ +import { securityCatalogReaderOf } from '@objectstack/core'; +import { DuplicateRecordError } from '@objectstack/objectql'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { PositionSchema } from '@objectstack/spec/identity'; @@ -273,6 +297,33 @@ async function nameStillCarried(ql: any, name: string): Promise { return Array.isArray(rows) && rows.length > 0; } +/** + * [ruling Q (a)] The refusal of a create, or a rename into `name`, when the + * security catalog already holds a definition of that name (module note, "a + * name the environment catalog already holds"). `null` when it holds none, + * when the name is a package's or a built-in's (S10's + * {@link heldPositionNameRefusal} answers those, first), or when no catalog is + * bound to this engine. A catalog read that fails throws its own loud error, + * and the caller undoes the write. + */ +async function environmentHeldPositionNameRefusal(ql: any, name: unknown): Promise { + if (typeof name !== 'string' || name === '' || packageHoldsPosition(ql, name)) return null; + const catalog = securityCatalogReaderOf(ql); + if (!catalog) return null; + const held = await catalog.resolve(POSITION_METADATA_TYPE, name); + if (!held) return null; + return new DuplicateRecordError( + POSITION_OBJECT, + new Error( + `position '${name}' already has a definition in this deployment's security catalog` + + `${held.packageId ? ` (declared by '${held.packageId}')` : ''}; under the single tenancy posture a ` + + 'position name is unique per deployment, and two holders of one name are never merged. Choose ' + + 'another name, or edit the existing position.', + ), + 'name', + ); +} + const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); function logError(logger: PositionWriteThroughLogger | undefined, message: string, cause: unknown, meta: Record): void { @@ -409,6 +460,12 @@ export function createPositionWriteThrough( const refusal = heldPositionNameRefusal(door, row.name); if (refusal) throw refusal; } + // [ruling Q (a)] …nor a name the environment catalog already holds: + // one holder per name per deployment, never a silent merge. + for (const row of createdRows) { + const refusal = await environmentHeldPositionNameRefusal(ql, row.name); + if (refusal) throw refusal; + } } catch (e) { await undoInsert(written); throw e; @@ -458,6 +515,12 @@ export function createPositionWriteThrough( const refusal = pre.name !== post.name ? heldPositionNameRefusal(door, post.name) : null; if (refusal) throw refusal; } + // [ruling Q (a)] A rename into a name the environment catalog holds is a + // second holder too. + for (const { pre, post } of posts) { + const refusal = pre.name !== post.name ? await environmentHeldPositionNameRefusal(ql, post.name) : null; + if (refusal) throw refusal; + } } catch (e) { await undoUpdate(targets, patch); throw e; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6a52dcdbcac..d066380d98d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; -import { Plugin, PluginContext, POSTURE_LADDER, isRowActive, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; +import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { AnchorBindingContext } from '@objectstack/spec/security'; @@ -100,7 +100,7 @@ import { PLATFORM_OWNER_WALL_BYPASS_EVENT, isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader, securityCatalogReaderOf, readDisabledCatalogNames } from '@objectstack/core'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy, @@ -156,7 +156,6 @@ import { DetailRecordNotFoundError, MasterReferenceMissingError, MaskedValueWriteError, - PermissionSetReadUnansweredError, } from './errors.js'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; @@ -1024,53 +1023,6 @@ function userFacingDenialMessage( return renderOperationMessage({ messageKey }, { locale, translate }); } -/** - * The `sys_permission_set` page the enforcement-plane loader asked for, or a - * REFUSAL — never a different empty. - * - * ## The shape, driven rather than declared - * - * `ObjectQL.find` resolves a BARE array of row objects. That is MEASURED, not - * read off `IDataEngine.find`'s declared `Promise`: a declared type is - * not proof here, because this repo also carries a `find()` that resolves a - * `QueryResult` envelope and never an array. `engine-find-bare-array.pin.test.ts` - * boots a real `ObjectQL` over a real `SqlDriver`, starts this plugin against - * it, and pins what this very loader receives — on a populated page and an - * empty one. The `{ records }` limb this function replaced was therefore - * unreachable: dead code that read as a contract. - * - * ## Why anything else REFUSES instead of returning `[]` - * - * This is the enforcement plane. A value this loader cannot read is a read that - * did not answer, and the one thing it must never become is "this principal has - * no permission sets" — a statement that silently withdraws grants that exist - * while the request keeps looking normal. `PermissionEvaluator - * .resolvePermissionSets` catches the refusal one frame up, keeps the request - * fail-closed exactly as before, and reports it once (#2565's warn, which the - * old swallow made unreachable). - * - * A non-object ELEMENT refuses for the same reason, and that half is the - * repair's other direction: it used to be dropped in silence by the trailing - * `r?.name === name` filter, which is the same invention one row at a time. - */ -function permissionSetPageOrRefuse(rows: unknown, names: readonly string[]): any[] { - if (!Array.isArray(rows)) { - throw new PermissionSetReadUnansweredError( - names, - `the engine resolved ${rows === null ? 'null' : typeof rows}, not an array of rows`, - ); - } - for (const row of rows) { - if (!row || typeof row !== 'object') { - throw new PermissionSetReadUnansweredError( - names, - `the page carried a ${row === null ? 'null' : typeof row} where a row object was contracted`, - ); - } - } - return rows; -} - /** * [ADR-0058 D4] The policies whose predicate the write post-image check * compiles, out of the policies that apply to this principal, object and write @@ -1380,13 +1332,12 @@ export class SecurityPlugin implements Plugin { */ private readonly objectSecurityMetaCache = new Map(); /** - * Permission-set loader FACTORY, parameterised by the caller's organization. - * A factory rather than a bare loader because the read is organization-scoped - * and the organization is a per-request fact — see where it is built for why - * an unscoped by-name read stops being correct once the catalog is - * materialized per organization. + * [ADR-0131 D3/D4] The permission-set loader for names the metadata list and + * the bootstrap sets did not answer: the security catalog's definitions, by + * name. Environment-level — the catalog takes no organization — so one loader + * serves every caller. See where it is built. */ - private dbLoaderFor?: (organizationId?: string) => (names: string[]) => Promise; + private catalogSetLoader?: (names: string[]) => Promise; /** * The organization a permission-set resolution runs in — the caller's own @@ -1406,11 +1357,11 @@ export class SecurityPlugin implements Plugin { /** * The permission-set loader for ONE caller. `undefined` when no engine is - * wired, exactly as the un-parameterised loader used to be, so - * `resolvePermissionSets` keeps its "no db source" branch. + * wired, so `resolvePermissionSets` keeps its "no third source" branch. The + * caller does not choose the answer: the catalog is environment-level. */ - private dbLoaderForContext(context: any): ((names: string[]) => Promise) | undefined { - return this.dbLoaderFor?.(this.callerOrganizationId(context)); + private dbLoaderForContext(_context: any): ((names: string[]) => Promise) | undefined { + return this.catalogSetLoader; } /** * [#10757] Per-EXECUTION-CONTEXT memo for @@ -2021,156 +1972,42 @@ export class SecurityPlugin implements Plugin { ); } - // Construct a dbLoader FACTORY once that lets resolvePermissionSets - // surface user-defined permission sets from `sys_permission_set` - // (created via the admin UI) in addition to plugin-registered ones. + // [ADR-0131 D3/D4] The third source `resolvePermissionSets` asks, for the + // names the metadata list and the bootstrap sets did not answer: the + // security catalog's definition of each name (`securityCatalogReaderOf`, + // the reader `bindSecurityCatalogReader` attached to this engine in + // `start()`) — the environment registry, where every package manifest's + // sets and every environment-authored set hydrated from `sys_metadata` + // live. It is the same read the authorization resolver takes a set's body + // from, so enforcement and resolution read one source. // - // Scoped to the CALLER's organization rather than unscoped. Once the - // catalog is materialized per organization, several organizations hold a - // row for the same name, and a by-name `$in` under a bare - // `{ isSystem: true }` resolves whichever copy the driver ordered first — - // one tenant's grants answering another tenant's request. Threading the - // organization routes the read through `SqlDriver.applyTenantScope` (the - // governed chokepoint, unchanged by this work), and - // `resolveOwnOrganizationRow` then prefers this organization's own row over - // any organization-less one still standing. + // ⛔ No `sys_permission_set` row is read. A set that exists only as a row + // (an organization's Setup-authored set under a wall, a row whose + // definition was never saved) has no registry home, and the resolver + // already grants nothing through it (#15196 Q3 = A); a row fallback here + // would grant its object map anyway — a second source the cutover retires + // (#22601 → B). // - // The `limit` moves with it. `names.length` was exactly right while one row - // existed per name, and is a TRUNCATION the moment copies exist: the driver - // returns this organization's rows AND any organization-less ones, so the - // cap must admit both or a caller silently loses sets. It stays bounded — a - // multiple of the names asked for, never unbounded. - const dbLoaderFor = ql - ? (organizationId?: string) => async (names: string[]) => { - // ⛔ NO `catch` here, and ⛔ no invented empty below — this seam is - // the DROP-shaped half of this cleanup and is repaired in the OPPOSITE - // direction from the dead limbs elsewhere in this plugin. - // - // Three distinct facts used to leave here as one value, `[]`: the read - // succeeded on an empty catalog; the read THREW; the read resolved - // something this loader could not read. Only the first is an answer. - // The other two were inventions, and this is the enforcement plane — - // "no permission sets" silently withdraws grants that EXIST while - // every request still looks completely normal. The residue comment - // below already names that failure ("revokes standing access with no - // signal at the moment of loss"); the swallow above it produced the - // same loss for a whole page at once. - // - // The refusal is not this seam's invention: `resolvePermissionSets` - // ALREADY declares how a throwing loader is handled — it catches, the - // unresolved sets grant nothing (fail closed, unchanged), and the - // failure is named in a warn, because without it "a transient DB error - // makes custom permission sets silently vanish and the resulting 403s - // are undiagnosable" (#2565). Swallowing here made that warn - // UNREACHABLE. Letting the read fault propagate is what re-arms a - // diagnostic this repo had already built, and it is the direction the - // 2026-08-11 store-fault ruling settles: a fault propagates. - // [#20555] With NO active organization the read asks for the - // organization-less rows ONLY. `seedCtx(undefined)` carries no - // tenant, and `applyTenantScope` reads "no tenant" as an unscoped - // path, so a bare by-name read returned every organization's row of - // each name — and `resolveOwnOrganizationRow` answers an undefined - // organization with whichever row came first. The names asked for - // include the caller's POSITIONS (the fold in - // `resolvePermissionSetsForContextUnmemoized`), and with no active - // organization those still carry every membership's role plus the - // `everyone` anchor. Measured over a real `SqlDriver`: a member of - // one organization, with none active, resolved a set ANOTHER - // organization had authored under the name `org_member` — its - // `systemPermissions` and its object map, view/modify-all included — - // and a GLOBAL grant resolved another organization's same-named copy - // in place of the global row it named. - // - // The rule is the one `resolveUserAuthzGrants` applies to grant rows, - // and ADR-0123 D2's "tenant-scoped reads resolve to nothing": a row - // scoped to an organization applies only while that organization is - // active; an organization-less row applies everywhere. With a tenant - // the driver's scope already returns exactly those two classes. With - // none, the predicate below is that same scope, pushed into the read - // rather than filtered after it — after the `limit`, other - // organizations' copies could crowd out the global row this caller - // does hold. - const where = organizationId - ? { name: { $in: names } } - : { name: { $in: names }, organization_id: null }; - const rows = await ql.find( - 'sys_permission_set', - { where, limit: Math.max(names.length * 4, 20) }, - { context: seedCtx(organizationId) }, - ); - const fetched = permissionSetPageOrRefuse(rows, names); - // One row per NAME: this organization's own where it has one, an - // organization-less leftover only where it does not. - // - // The residue arm is load-bearing and was missing (#11121 shipped the - // comment without it, and its suite covers seeding rather than this - // loader). `resolveOwnOrganizationRow` is written for SEEDERS, where - // refusing to see a residue as "already seeded" is the whole point — - // so it never returns one as `own`. ENFORCEMENT wants the opposite - // reading: an organization-less row is still a row this principal was - // granted, and dropping it revokes standing access with no signal at - // the moment of loss — the failure mode this catalog's own header, - // and `resolve-authz-context`'s `sys_position` read, both call out as - // the thing not to do. The asymmetry was observable on a single row: - // its `system_permissions` and `tab_permissions` kept applying (that - // read is unscoped by id) while its `object_permissions` and - // `admin_scope` silently stopped. - // - // Preference order is unchanged and still closes the cross-tenant - // bleed #11121 fixed: this organization's own row WINS wherever it - // exists, and a leftover is consulted only in its absence. With no - // active organization every row here is organization-less (the read - // above asked for nothing else), so `own` is one of those. - const byName = new Map(); - for (const name of new Set(names)) { - const { own, organizationLessResidue } = resolveOwnOrganizationRow( - fetched.filter((r: any) => r?.name === name), - organizationId, - ); - const row = own ?? organizationLessResidue; - if (row) byName.set(name, row); - } - const all = Array.from(byName.values()); - // [ADR-0049] A DEACTIVATED set grants nothing. Not defence in depth - // that nothing reaches: `resolvePermissionSetsForContext` requests - // `context.positions` as permission-set NAMES too (a position name is - // commonly reused as a set name), so an ACTIVE position whose name - // matches a DEACTIVATED `sys_permission_set` row arrives here with - // that name still standing — this loader is the only place that read - // is judged. Filtered in memory rather than by a `where` predicate: - // `active: true` would also drop rows whose column is NULL (rows - // predating the field), and boolean `where` coercion differs per - // driver. `isRowActive` is the same predicate the core resolver and - // the break-glass guard use, so the three cannot drift. - const list = all.filter((r: any) => isRowActive(r)); - const parseJson = (v: any, fallback: any) => { - if (typeof v !== 'string') return v ?? fallback; - try { return JSON.parse(v || JSON.stringify(fallback)); } catch { return fallback; } - }; - return list.map((r: any) => ({ - name: r.name, - label: r.label, - objects: parseJson(r.object_permissions, {}), - fields: parseJson(r.field_permissions, {}), - systemPermissions: parseJson(r.system_permissions, []), - // [#7616] Hydrate the tab column too. Nothing on the DATA plane - // reads `tabPermissions` (the evaluator never mentions it), so this - // is inert for enforcement — but `resolvePermissionSetsForContext` - // is published on the `security` service as returning the sets - // WHOLE, and a loader that dropped this column would make that - // declaration false for every DB-authored set: the UI-plane copy in - // `/me/apps` reads exactly `tab_permissions` off the same row. - // Declared ≠ delivered is the failure this contract exists to - // prevent, so the column is loaded where the promise is made. The - // row is already fetched in full — no extra query, one JSON parse. - tabPermissions: parseJson(r.tab_permissions, {}), - // [ADR-0090 D12] Hydrate the delegated-admin scope so the gate can - // resolve a DB-authored delegate's authority. Null column → absent. - ...(r.admin_scope ? { adminScope: parseJson(r.admin_scope, undefined) } : {}), - })); - } - : undefined; - this.dbLoaderFor = dbLoaderFor; + // ⛔ No `catch` either. A catalog read that did not happen throws the + // reader's own `AuthzStoreUnavailableError`, and `resolvePermissionSets` + // catches it one frame up: the unresolved sets grant nothing (fail closed) + // and the failure is named in its warn (#2565). Swallowing it here would + // report "no such set" for a read that never answered. + // + // Whether a set is switched off is not this loader's question: the names + // it is asked for arrive already judged by the resolver's ledger read, + // except a POSITION name folded into the request, which + // `resolvePermissionSetsForContextUnmemoized` judges itself. + this.catalogSetLoader = async (names: string[]) => { + const catalog = securityCatalogReaderOf(ql); + if (!catalog) return []; + const out: PermissionSet[] = []; + for (const name of new Set(names)) { + const entry = await catalog.resolve('permission', name); + if (entry) out.push(entry.definition as unknown as PermissionSet); + } + return out; + }; // [ADR-0090 D12] Delegated-admin gate shares the SAME permission-set // resolution as the CRUD middleware, so a delegate's authority and their @@ -7117,6 +6954,7 @@ export class SecurityPlugin implements Plugin { this.dbLoaderForContext(context), { logger: this.logger }, ); + permissionSets = await this.dropDeactivatedFoldedSets(positions, explicitPermissionSets, baselineApplied, permissionSets); // [#13419 执行要点 3] Report — never alter — a grant that exists ONLY because // this method folded a POSITION name into the permission-set request above. this.reportNameFoldCollisions(positions, explicitPermissionSets, baselineApplied, permissionSets); @@ -7141,6 +6979,43 @@ export class SecurityPlugin implements Plugin { return permissionSets; } + /** + * [ADR-0049, ADR-0131 D3] Drop a set that reached the resolution ONLY because + * a POSITION of the same name was folded into the request, when the + * activation ledger switched that set off. + * + * Every other name arrives already judged: `context.permissions` is the + * resolver's output, which drops a deactivated set (§6b), and the baseline + * is the deployment's configuration, applied regardless. A folded position + * name is the one name nobody judged as a SET — the position is in effect, + * but the set that shares its name may not be. The row loader this replaced + * judged it with the catalog row's `active` column; the switch now lives in + * `sys_metadata_activation`, read through the resolver's own + * `readDisabledCatalogNames`, so this path and the resolver cannot disagree. + * + * The ledger is read only when a folded name actually resolved, so a request + * with no such collision issues no read. A read that fails throws + * (`AuthzStoreUnavailableError`), and the request fails closed. + */ + private async dropDeactivatedFoldedSets( + positions: unknown, + explicitPermissionSets: unknown, + baselineApplied: string[], + resolved: PermissionSet[], + ): Promise { + if (!Array.isArray(positions) || positions.length === 0 || resolved.length === 0) return resolved; + const judged = new Set(baselineApplied); + for (const n of Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []) { + if (typeof n === 'string') judged.add(n); + } + const resolvedNames = new Set(resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string')); + const folded = positions.filter((n): n is string => typeof n === 'string' && resolvedNames.has(n) && !judged.has(n)); + if (folded.length === 0) return resolved; + const disabled = await readDisabledCatalogNames(this.ql, [], [...new Set(folded)]); + if (disabled.permissions.size === 0) return resolved; + return resolved.filter((ps) => !disabled.permissions.has((ps as any)?.name)); + } + /** * [#13419 执行要点 3, warning half] Report a permission-set name that collides * with a POSITION name and has NO junction row — the ungoverned half of the From 06c25c52d9603dd978de0443b1720c13ec8b8895 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:56:51 +0000 Subject: [PATCH 25/44] plugin-security: the delegated-admin gate's suites read the catalog; per-organization position pins retire Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .changeset/15204-s2a-security-readers.md | 40 ++ .../src/__tests__/security-catalog.testkit.ts | 11 +- .../src/delegated-admin-gate-catalog.test.ts | 164 +++++++ ...ed-admin-gate-holding-organization.test.ts | 7 + ...d-admin-gate-position-organization.test.ts | 414 ------------------ .../src/delegated-admin-gate.test.ts | 56 ++- .../src/delegated-admin-gate.ts | 8 +- .../grant-holder-membership-refusal.test.ts | 13 +- .../src/grant-permission-set-name.ts | 87 +--- .../plugin-security/src/security-plugin.ts | 16 - 10 files changed, 273 insertions(+), 543 deletions(-) create mode 100644 .changeset/15204-s2a-security-readers.md create mode 100644 packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts delete mode 100644 packages/plugins/plugin-security/src/delegated-admin-gate-position-organization.test.ts diff --git a/.changeset/15204-s2a-security-readers.md b/.changeset/15204-s2a-security-readers.md new file mode 100644 index 00000000000..2daa60db6a6 --- /dev/null +++ b/.changeset/15204-s2a-security-readers.md @@ -0,0 +1,40 @@ +--- +'@objectstack/core': minor +'@objectstack/plugin-security': minor +--- + +feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) + +Clause-②: yes + + + +**BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. + +**Permission-set resolution (`@objectstack/plugin-security`).** + +- FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. +- TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. +- Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. +- A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. + +**Delegated administration (ADR-0090 D12).** + +- FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. +- TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). +- A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. +- A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". + +**The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. + +**Position assignments (`sys_user_position.position`).** + +- FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. +- TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. + +**One position name per deployment under `single`.** + +- FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). +- TO: refused `409 UNIQUE_VIOLATION` on `name`, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. + +**`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. 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 74913a4b457..da4eea9f4e6 100644 --- a/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts +++ b/packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts @@ -54,7 +54,7 @@ export function catalogFromTables(tables: Record 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'], ['admin_scope', 'adminScope']] as const) { const value = parsed(row[column] ?? row[key]); if (value !== undefined && value !== null) definition[key] = value; } @@ -65,9 +65,16 @@ export function catalogFromTables(tables: Record + positionRows.some((r) => r.name === name && (r.delegatable === true || r.delegatable === 1 || r.delegatable === '1')); return { permissions, - positions: positions.flatMap((f) => (f.fate === 'conflicting' ? [] : [{ name: f.name, permissionSets: [...f.permissionSets] }])), + positions: positions.flatMap((f) => (f.fate === 'conflicting' + ? [] + : [{ name: f.name, permissionSets: [...f.permissionSets], ...(delegatable(f.name) ? { delegatable: true } : {}) }])), }; } diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts new file mode 100644 index 00000000000..7256ccd7e8c --- /dev/null +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D12 × ADR-0131 D3/D4] The delegated-administration gate judges a + * position by its DEFINITION in the security catalog. + * + * The authorization resolver grants a held position the sets its definition's + * `permissionSets` names, read from the environment registry, and reads no + * `sys_position_permission_set` junction row. The gate's containment check and + * the assignable-positions listing must judge exactly that binding, or a + * delegate could hand out (and a picker could offer) a position whose real + * grant the gate never saw. These pins hold the gate to the catalog: + * + * - a binding declared only on the definition is judged; + * - a junction row binds nothing, so it neither refuses nor launders; + * - a position declared only in the registry (no `sys_position` row) is + * assignable and listed; + * - a set name the definition carries but the catalog does not hold yet must + * be allowlisted too (it starts granting the moment it is authored); + * - a catalog read that fails refuses the write instead of approving it as + * "distributes nothing". + */ + +import { describe, it, expect } from 'vitest'; +import { AuthzStoreUnavailableError, bindSecurityCatalogReader } from '@objectstack/core'; +import { DelegatedAdminGate } from './delegated-admin-gate.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const EAST_SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + manageBindings: false, + authorEnvironmentSets: false, + assignablePermissionSets: ['sales_user'], +}; + +const SETS: Record = { + tenant_admin: [{ name: 'admin_full', objects: { '*': { allowRead: true, modifyAllRecords: true } } }], + delegate: [{ name: 'east_admin', objects: {}, adminScope: EAST_SCOPE }], +}; + +const CATALOG = { + positions: [ + // Declared only on the definition: no junction row binds anything below. + { name: 'rep', permissionSets: ['sales_user'] }, + { name: 'mixed', permissionSets: ['sales_user', 'finance_admin'] }, + // No `sys_position` row carries this one. + { name: 'registry_only', permissionSets: ['sales_user'] }, + // Names a set nobody has authored yet. + { name: 'pending', permissionSets: ['sales_user', 'not_yet_authored'] }, + { name: 'everyone', permissionSets: ['member_default'] }, + ], + permissions: [{ name: 'sales_user' }, { name: 'finance_admin' }, { name: 'member_default' }], +}; + +function makeQl(tables: Record = {}) { + const all: Record = { + sys_business_unit: [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, + ], + // Rows say something else on purpose: `rep` is bound to the set NOT on + // the allowlist, and `mixed` to nothing at all. + sys_position: [ + { id: 'pos_rep', name: 'rep' }, + { id: 'pos_mixed', name: 'mixed' }, + { id: 'pos_pending', name: 'pending' }, + ], + sys_permission_set: [ + { id: 'ps_sales', name: 'sales_user' }, + { id: 'ps_fin', name: 'finance_admin' }, + ], + sys_position_permission_set: [{ id: 'b1', position_id: 'pos_rep', permission_set_id: 'ps_fin' }], + sys_user_position: [], + sys_business_unit_member: [], + sys_user: [], + ...tables, + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + return { + async find(object: string, opts: any) { + const rows = (all[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + return (all[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; + }, + } as any; +} + +const gateOver = (ql: any) => + new DelegatedAdminGate({ ql, resolveSets: async (ctx: any) => SETS[ctx?.principal ?? ''] ?? [] }); + +const assign = (gate: DelegatedAdminGate, position: string) => + gate.assert({ + object: 'sys_user_position', + operation: 'insert', + data: { user_id: 'u_east_1', position, business_unit_id: 'bu_east' }, + context: { principal: 'delegate', userId: 'u_delegate' }, + }); + +async function refusal(p: Promise): Promise { + const err: any = await p.then(() => null, (e) => e); + expect(err, 'expected a refusal').not.toBeNull(); + return err; +} + +describe('DelegatedAdminGate — a position distributes what its definition declares', () => { + it('a binding declared only on the definition is judged: a non-allowlisted set refuses the assignment', async () => { + const err = await refusal(assign(gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)), 'mixed')); + expect(err).toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(String(err.message)).toMatch(/position 'mixed' distributes permission set 'finance_admin', which is not in the scope's allowlist/); + }); + + it("a junction row binds nothing: `rep`'s row binding finance_admin neither refuses nor counts", async () => { + await expect(assign(gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)), 'rep')).resolves.toBeUndefined(); + }); + + it('a position declared only in the registry (no sys_position row) is assignable', async () => { + await expect(assign(gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)), 'registry_only')).resolves.toBeUndefined(); + }); + + it('a set name the definition carries but the catalog does not hold yet must be allowlisted too', async () => { + const err = await refusal(assign(gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)), 'pending')); + expect(String(err.message)).toMatch(/position 'pending' distributes permission set 'not_yet_authored', which is not in the scope's allowlist/); + }); + + it('a catalog read that fails refuses the write; it is never read as "distributes nothing"', async () => { + const ql = makeQl(); + bindSecurityCatalogReader(ql, { + resolve: async () => { + throw new AuthzStoreUnavailableError('security catalog: registry (position)'); + }, + list: async () => [], + }); + const err = await refusal(assign(gateOver(ql), 'rep')); + expect(err).toBeInstanceOf(AuthzStoreUnavailableError); + }); +}); + +describe('DelegatedAdminGate — describeDelegableScope lists the catalog positions', () => { + it('a delegate is offered exactly the positions whose declared sets are all allowlisted (anchors excluded)', async () => { + const gate = gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)); + const report = await gate.describeDelegableScope(SETS.delegate, { userId: 'u_delegate' }); + expect(report.assignablePositions.sort()).toEqual(['registry_only', 'rep']); + }); + + it('a tenant administrator is offered every position the catalog holds, a registry-only one included', async () => { + const gate = gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)); + const report = await gate.describeDelegableScope(SETS.tenant_admin, { userId: 'u_admin' }); + expect(report.assignablePositions.sort()).toEqual(['mixed', 'pending', 'registry_only', 'rep']); + }); + + it('with no catalog bound, nothing is offered (fail closed) — a row is not a position', async () => { + const report = await gateOver(makeQl()).describeDelegableScope(SETS.tenant_admin, { userId: 'u_admin' }); + expect(report.assignablePositions).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index cd0a550efb3..ea1fd91b215 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -36,6 +36,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { PermissionSet } from '@objectstack/spec/security'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const ORG_A = 'org_a_acme'; /** Sorts BEFORE every `org_a` / `*_a_*` id, like the sibling suites. */ @@ -155,6 +156,12 @@ async function hold( } function gateOn(engine: ObjectQL) { + // [ADR-0131 D3/D4] The position and the set are the security catalog's + // definitions (environment-level); the rows above are each organization's. + bindTestSecurityCatalog(engine, { + positions: [{ name: 'field_lead', delegatable: true }], + permissions: [{ name: 'sales_user' }], + }); return new DelegatedAdminGate({ ql: engine, resolveSets: async (context: any) => (context?.principal === 'binder' ? BINDER_SETS : []), diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-position-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-position-organization.test.ts deleted file mode 100644 index 142e9be88cf..00000000000 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-position-organization.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The delegated-admin gate resolves a position NAME inside the caller's own - * organization — pinned over a real engine, one witness per authority read. - * - * ## The defect this exists to keep closed - * - * `sys_position` is a per-organization catalog upserted by - * `(name, organization_id)`, and `name` carries no installation-wide - * uniqueness, so in a single-database multi-org posture — ADR-0105 D1 - * `group` / `isolated`, which ADR-0132 ships open — two organizations may each - * hold a position of the same name. Two gate reads looked the position up by - * NAME alone, `limit: 1`, under a bare `{ isSystem: true }` context carrying no - * tenant, so whichever id the driver ordered first answered: - * - * - `positionIsDelegatable` — may this position be SELF-DELEGATED - * (ADR-0091 D3 rule 5)? - * - `setsBoundToPosition` — which permission sets does it distribute (the - * allowlist and containment tests of a delegated assignment, and D3 rule 6)? - * - * The sibling half — a scope's business-unit anchor — is pinned in - * `delegated-admin-gate-cross-organization.test.ts`; this file reuses its - * harness and its id ordering: the OTHER organization's ids sort first - * (`*_0_*` before `*_a_*`), and the SQL driver's `limit` read is - * `ORDER BY id ASC`, so under the old resolution every organization-A answer - * below was organization B's. - * - * Fail closed: a position name with no row in the caller's organization is not - * delegatable and distributes no permission sets — never another - * organization's row, never an unscoped read. That matches the runtime - * resolver, which reads position-bound sets through the caller's tenant. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import type { PermissionSet } from '@objectstack/spec/security'; -import { DelegatedAdminGate } from './delegated-admin-gate.js'; - -const ORG_A = 'org_a_acme'; -/** Deliberately sorts BEFORE every `org_a` / `*_a_*` id — the side the old read picked. */ -const ORG_B = 'org_0_globex'; - -const text = (name: string) => ({ name, type: 'text' as const }); -const object = (name: string, fields: string[]) => ({ - name, - label: name, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - organization_id: text('organization_id'), - ...Object.fromEntries(fields.map((f) => [f, text(f)])), - }, -}); - -const OBJECTS = [ - object('sys_business_unit', ['name', 'parent_business_unit_id']), - object('sys_position', ['name', 'delegatable']), - object('sys_permission_set', ['name', 'admin_scope']), - object('sys_position_permission_set', ['position_id', 'permission_set_id']), - object('sys_user_position', [ - 'user_id', 'position', 'business_unit_id', 'granted_by', 'delegated_from', 'valid_until', 'reason', - ]), - object('sys_business_unit_member', ['user_id', 'business_unit_id']), - object('sys_user', ['primary_business_unit_id']), -]; - -/** The delegate's scope: anchored at the unit NAMED `sales`, handing out `sales_user` only. */ -const SALES_SCOPE = { - businessUnit: 'sales', - includeSubtree: true, - manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, - assignablePermissionSets: ['sales_user'], -}; - -const DELEGATE_SETS = [ - { - name: 'sales_admin', - objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - adminScope: SALES_SCOPE, - }, -] as unknown as PermissionSet[]; - -const engines: ObjectQL[] = []; -afterEach(async () => { - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -const sysCtx = (organizationId: string) => ({ isSystem: true, tenantId: organizationId }); - -async function bootEngine() { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.delegated-admin-position-organization', - name: 'Delegated admin position organization scope', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: OBJECTS, - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** How one organization configured one position name. */ -interface PositionSpec { - name: string; - delegatable: boolean; - /** Permission-set NAMES the position distributes in this organization. */ - sets: string[]; - /** Those of `sets` that carry an `adminScope` in this organization. */ - adminSets?: string[]; -} - -/** - * One organization's slice: a unit named `sales` with one child, one member, - * a holder who DIRECTLY holds every position (for self-delegation), and the - * positions exactly as this organization configured them. `idPrefix` decides - * how this organization's ids sort. - */ -async function seedOrganization( - engine: ObjectQL, - organizationId: string, - idPrefix: string, - positions: PositionSpec[], -) { - const ctx = sysCtx(organizationId); - const ids = { - sales: `bu_${idPrefix}_sales`, - salesEast: `bu_${idPrefix}_sales_east`, - member: `usr_${idPrefix}_member`, - holder: `usr_${idPrefix}_holder`, - positionId: (name: string) => `pos_${idPrefix}_${name}`, - }; - await engine.insert('sys_business_unit', [ - { id: ids.sales, name: 'sales', parent_business_unit_id: null }, - { id: ids.salesEast, name: 'sales_east', parent_business_unit_id: ids.sales }, - ], { context: ctx } as any); - await engine.insert('sys_user', [ - { id: ids.member, primary_business_unit_id: ids.salesEast }, - { id: ids.holder, primary_business_unit_id: ids.salesEast }, - ], { context: ctx } as any); - await engine.insert('sys_business_unit_member', [ - { id: `mem_${idPrefix}_member`, user_id: ids.member, business_unit_id: ids.salesEast }, - { id: `mem_${idPrefix}_holder`, user_id: ids.holder, business_unit_id: ids.salesEast }, - ], { context: ctx } as any); - - const setNames = [...new Set(positions.flatMap((p) => p.sets))]; - const adminSets = new Set(positions.flatMap((p) => p.adminSets ?? [])); - if (setNames.length > 0) { - await engine.insert('sys_permission_set', setNames.map((n) => ({ - id: `ps_${idPrefix}_${n}`, - name: n, - admin_scope: adminSets.has(n) ? JSON.stringify(SALES_SCOPE) : null, - })), { context: ctx } as any); - } - for (const p of positions) { - await engine.insert('sys_position', [ - { id: ids.positionId(p.name), name: p.name, delegatable: p.delegatable ? '1' : '0' }, - ], { context: ctx } as any); - if (p.sets.length > 0) { - await engine.insert('sys_position_permission_set', p.sets.map((n) => ({ - id: `bind_${idPrefix}_${p.name}_${n}`, - position_id: ids.positionId(p.name), - permission_set_id: `ps_${idPrefix}_${n}`, - })), { context: ctx } as any); - } - // The holder holds it directly — self-delegation rule 4 is satisfied, so - // rules 5 and 6 are what decide. - await engine.insert('sys_user_position', [ - { id: `up_${idPrefix}_holder_${p.name}`, user_id: ids.holder, position: p.name, business_unit_id: ids.salesEast }, - ], { context: ctx } as any); - } - return ids; -} - -function gateOn(engine: ObjectQL) { - return new DelegatedAdminGate({ - ql: engine, - resolveSets: async (context: any) => (context?.principal === 'delegate' ? DELEGATE_SETS : []), - }); -} - -const DAY = 24 * 60 * 60 * 1000; - -/** A holder delegates their OWN position to a colleague, inside their organization. */ -const selfDelegate = ( - gate: DelegatedAdminGate, - organizationId: string, - ids: { holder: string; member: string }, - position: string, -) => - gate.assert({ - object: 'sys_user_position', - operation: 'insert', - data: { - user_id: ids.member, - position, - delegated_from: ids.holder, - valid_until: new Date(Date.now() + 7 * DAY).toISOString(), - reason: 'annual leave', - }, - context: { principal: 'holder', userId: ids.holder, tenantId: organizationId }, - }); - -/** A delegated administrator assigns a position inside their `sales` subtree. */ -const assignAsDelegate = ( - gate: DelegatedAdminGate, - organizationId: string, - ids: { salesEast: string; member: string }, - position: string, -) => - gate.assert({ - object: 'sys_user_position', - operation: 'insert', - data: { user_id: ids.member, position, business_unit_id: ids.salesEast }, - context: { principal: 'delegate', userId: 'usr_delegate', tenantId: organizationId }, - }); - -// ── positionIsDelegatable ───────────────────────────────────────────────── - -describe('DelegatedAdminGate — whether a position is delegatable is answered by the caller\'s own organization', () => { - /** Same name, opposite answers: only org B opted `field_lead` in to delegation. */ - const seedBoth = async (engine: ObjectQL) => { - const b = await seedOrganization(engine, ORG_B, '0', [{ name: 'field_lead', delegatable: true, sets: [] }]); - const a = await seedOrganization(engine, ORG_A, 'a', [{ name: 'field_lead', delegatable: false, sets: [] }]); - return { a, b }; - }; - - it('ground truth: both organizations hold `field_lead`, org B\'s row sorts first and is the delegatable one', async () => { - const engine = await bootEngine(); - const { a, b } = await seedBoth(engine); - - const driver: any = (engine as any).getDriver('sys_position'); - const raw = await driver.knex('sys_position').where({ name: 'field_lead' }).orderBy('id', 'asc'); - expect(raw.map((r: any) => r.id)).toEqual([b.positionId('field_lead'), a.positionId('field_lead')]); - expect(raw.map((r: any) => [r.organization_id, r.delegatable])).toEqual([[ORG_B, '1'], [ORG_A, '0']]); - }); - - it('org A, which did NOT opt in, refuses the self-delegation — org B\'s opt-in does not answer for it', async () => { - const engine = await bootEngine(); - const { a } = await seedBoth(engine); - - await expect(selfDelegate(gateOn(engine), ORG_A, a, 'field_lead')).rejects.toThrow( - /position 'field_lead' is not delegatable/, - ); - }); - - it('org B, which opted in, approves the self-delegation', async () => { - const engine = await bootEngine(); - const { b } = await seedBoth(engine); - - await expect(selfDelegate(gateOn(engine), ORG_B, b, 'field_lead')).resolves.toBeUndefined(); - }); - - it('a position with NO row in the caller\'s organization is not delegatable (fail closed)', async () => { - const engine = await bootEngine(); - // `night_shift` exists — delegatable — in org B only. The org A holder's - // assignment row names it, but org A's catalog has no such position. - await seedOrganization(engine, ORG_B, '0', [{ name: 'night_shift', delegatable: true, sets: [] }]); - const a = await seedOrganization(engine, ORG_A, 'a', []); - await engine.insert('sys_user_position', [ - { id: 'up_a_holder_night_shift', user_id: a.holder, position: 'night_shift', business_unit_id: a.salesEast }, - ], { context: sysCtx(ORG_A) } as any); - - await expect(selfDelegate(gateOn(engine), ORG_A, a, 'night_shift')).rejects.toThrow( - /position 'night_shift' is not delegatable/, - ); - }); -}); - -// ── setsBoundToPosition ─────────────────────────────────────────────────── - -describe('DelegatedAdminGate — the sets a position distributes are the caller\'s own organization\'s bindings', () => { - /** - * Two names, each bound differently per organization. The delegate's scope - * allowlists `sales_user` only, in both organizations. - * - `lead` — org A binds `sales_user` (allowlisted); org B binds `finance_user` (not). - * - `closer` — org A binds `finance_user` (not); org B binds `sales_user` (allowlisted). - */ - const seedBoth = async (engine: ObjectQL) => { - const b = await seedOrganization(engine, ORG_B, '0', [ - { name: 'lead', delegatable: false, sets: ['finance_user'] }, - { name: 'closer', delegatable: false, sets: ['sales_user'] }, - ]); - const a = await seedOrganization(engine, ORG_A, 'a', [ - { name: 'lead', delegatable: false, sets: ['sales_user'] }, - { name: 'closer', delegatable: false, sets: ['finance_user'] }, - ]); - return { a, b }; - }; - - it('org A judges `lead` by its OWN binding (allowlisted) — approved', async () => { - const engine = await bootEngine(); - const { a } = await seedBoth(engine); - - await expect(assignAsDelegate(gateOn(engine), ORG_A, a, 'lead')).resolves.toBeUndefined(); - }); - - it('org A judges `closer` by its OWN binding (not allowlisted) — refused, org B\'s binding does not launder it', async () => { - const engine = await bootEngine(); - const { a } = await seedBoth(engine); - - await expect(assignAsDelegate(gateOn(engine), ORG_A, a, 'closer')).rejects.toThrow( - /position 'closer' distributes permission set 'finance_user', which is not in the scope's allowlist/, - ); - }); - - it('org B gets the mirror-image answers from its own bindings', async () => { - const engine = await bootEngine(); - const { b } = await seedBoth(engine); - const gate = gateOn(engine); - - await expect(assignAsDelegate(gate, ORG_B, b, 'closer')).resolves.toBeUndefined(); - await expect(assignAsDelegate(gate, ORG_B, b, 'lead')).rejects.toThrow( - /position 'lead' distributes permission set 'finance_user', which is not in the scope's allowlist/, - ); - }); - - it('describeDelegableScope offers each organization only the positions its OWN bindings allow', async () => { - const engine = await bootEngine(); - await seedBoth(engine); - const gate = gateOn(engine); - - const reportA = await gate.describeDelegableScope(DELEGATE_SETS, { userId: 'usr_delegate', tenantId: ORG_A }); - const reportB = await gate.describeDelegableScope(DELEGATE_SETS, { userId: 'usr_delegate', tenantId: ORG_B }); - expect(reportA.assignablePositions).toEqual(['lead']); - expect(reportB.assignablePositions).toEqual(['closer']); - }); - - it('self-delegation rule 6 reads the caller\'s own bindings — an admin set bound only in org B does not block org A', async () => { - const engine = await bootEngine(); - // Both opted `relief` in; only org B's `relief` distributes an ADMIN set. - const b = await seedOrganization(engine, ORG_B, '0', [ - { name: 'relief', delegatable: true, sets: ['relief_admin'], adminSets: ['relief_admin'] }, - ]); - const a = await seedOrganization(engine, ORG_A, 'a', [{ name: 'relief', delegatable: true, sets: ['sales_user'] }]); - const gate = gateOn(engine); - - await expect(selfDelegate(gate, ORG_A, a, 'relief')).resolves.toBeUndefined(); - await expect(selfDelegate(gate, ORG_B, b, 'relief')).rejects.toThrow( - /distributes the admin set 'relief_admin' — administration cannot be self-delegated/, - ); - }); - - it('a position with NO row in the caller\'s organization distributes no sets — org B\'s bindings never judge org A', async () => { - const engine = await bootEngine(); - // `ghost` exists only in org B, bound to a set org A's scope does not allowlist. - await seedOrganization(engine, ORG_B, '0', [{ name: 'ghost', delegatable: false, sets: ['finance_user'] }]); - const a = await seedOrganization(engine, ORG_A, 'a', []); - const gate = gateOn(engine); - - // Nothing to offer: org A's catalog has no `ghost`. - const report = await gate.describeDelegableScope(DELEGATE_SETS, { userId: 'usr_delegate', tenantId: ORG_A }); - expect(report.assignablePositions).toEqual([]); - // And the write is judged against the empty binding set org A actually has — - // org B's `finance_user` is not what refuses or approves it. The assignment - // grants nothing at runtime either: the resolver reads position-bound sets - // through the caller's tenant. - await expect(assignAsDelegate(gate, ORG_A, a, 'ghost')).resolves.toBeUndefined(); - }); -}); - -// ── Controls ────────────────────────────────────────────────────────────── - -describe('DelegatedAdminGate — position reads: controls', () => { - it('control (dark) — one organization: its own delegatable + bound answers stand', async () => { - const engine = await bootEngine(); - const a = await seedOrganization(engine, ORG_A, 'a', [ - { name: 'field_lead', delegatable: true, sets: [] }, - { name: 'lead', delegatable: false, sets: ['sales_user'] }, - { name: 'closer', delegatable: false, sets: ['finance_user'] }, - ]); - const gate = gateOn(engine); - - await expect(selfDelegate(gate, ORG_A, a, 'field_lead')).resolves.toBeUndefined(); - await expect(assignAsDelegate(gate, ORG_A, a, 'lead')).resolves.toBeUndefined(); - await expect(assignAsDelegate(gate, ORG_A, a, 'closer')).rejects.toThrow(/not in the scope's allowlist/); - }); - - it('a `single`-posture caller (no organization in context) keeps the by-name answer', async () => { - const engine = await bootEngine(); - const a = await seedOrganization(engine, ORG_A, 'a', [ - { name: 'field_lead', delegatable: true, sets: [] }, - { name: 'closer', delegatable: false, sets: ['finance_user'] }, - ]); - const gate = gateOn(engine); - - await expect(gate.assert({ - object: 'sys_user_position', - operation: 'insert', - data: { - user_id: a.member, - position: 'field_lead', - delegated_from: a.holder, - valid_until: new Date(Date.now() + 7 * DAY).toISOString(), - reason: 'annual leave', - }, - context: { principal: 'holder', userId: a.holder }, - })).resolves.toBeUndefined(); - const report = await gate.describeDelegableScope(DELEGATE_SETS, { userId: 'usr_delegate' }); - expect(report.assignablePositions).toEqual(['field_lead']); - }); -}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 8cb7674ebe3..08ac0a49020 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { DelegatedAdminGate } from './delegated-admin-gate'; +import { bindCatalogFromTables, bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; /** * Fixture topology @@ -83,6 +84,9 @@ function makeHarness() { return rows[0] ?? null; }, } as any; + // [ADR-0131 D3/D4] The gate reads positions, their bindings and the set + // bodies from the security catalog: the one these rows convert to. + bindCatalogFromTables(ql, tables); // Resolved permission sets per principal — mirrors what // resolvePermissionSetsForContext would return for each context. @@ -376,9 +380,9 @@ describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => await expect(remove()).rejects.toThrow(/names no permission set/); }); - it('a grant naming a set no catalog row carries is refused', async () => { + it('a grant naming a set the security catalog does not hold is refused', async () => { storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user_retired' }); - await expect(remove()).rejects.toThrow(/has no catalog row/); + await expect(remove()).rejects.toThrow(/is not in the security catalog/); }); it('an update keeping the set is judged by the stored name; one re-pointing the id by the new id', async () => { @@ -502,6 +506,7 @@ function makeDelegationHarness(nowMs = T0) { return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; }, } as any; + bindCatalogFromTables(ql, tables); const gate = new DelegatedAdminGate({ ql, resolveSets: async () => [{ name: 'member_default', objects: {} }], @@ -653,6 +658,7 @@ function makeAnchoredDelegationHarness(nowMs = T0) { return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; }, } as any; + bindCatalogFromTables(ql, tables); const gate = new DelegatedAdminGate({ ql, resolveSets: async () => [{ name: 'member_default', objects: {} }], @@ -925,16 +931,14 @@ describe('DelegatedAdminGate — the anchor is picked by organization, not by ar }); }); -// ── A position NAME is resolved to the caller's own organization's row ───── -// The companion of `delegated-admin-gate-position-organization.test.ts` (a -// real engine), on the same context-ignoring double as the block above: every -// organization's `sys_position` rows — and an organization-less residue row — -// are handed to the gate, listed in id order, so the row-level selection is -// the only thing standing between another organization's row and the two -// position-keyed authority decisions (delegatable; bound sets). -describe('DelegatedAdminGate — a position is picked by organization, not by arrival order', () => { +// ── A position is the security catalog's definition, not an organization's row ─ +// [ADR-0131 D3/D4] The two position-keyed authority decisions (delegatable; +// bound sets) read the position's DEFINITION from the environment catalog by +// name. Every organization's `sys_position` rows and junction rows are handed +// to the gate on a context-ignoring double, and none of them is what decides: +// the catalog is one per deployment, and a row is not a position. +describe('DelegatedAdminGate — a position is the catalog definition, not an organization row', () => { const ORG_A = 'org_a_acme'; - /** Sorts first, and is listed first — the row an unscoped `limit: 1` read answered with. */ const ORG_B = 'org_0_globex'; const FUTURE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); @@ -954,24 +958,23 @@ describe('DelegatedAdminGate — a position is picked by organization, not by ar sys_business_unit: [ { id: 'bu_a_sales', name: 'sales', parent_business_unit_id: null, organization_id: ORG_A }, ], + // Rows that SAY otherwise: org B's `closer` is delegatable and bound to + // the allowlisted set, and `night_shift` exists only as a row. sys_position: [ - // An organization-less residue row: invalid state under a walled - // posture, so it answers for no organization's caller. { id: 'pos_', name: 'night_shift', delegatable: true, organization_id: null }, { id: 'pos_0_closer', name: 'closer', delegatable: true, organization_id: ORG_B }, - { id: 'pos_a_closer', name: 'closer', delegatable: false, organization_id: ORG_A }, + { id: 'pos_0_greeter', name: 'greeter', delegatable: false, organization_id: ORG_B }, ], sys_permission_set: [ { id: 'ps_0_sales_user', name: 'sales_user', organization_id: ORG_B }, - { id: 'ps_a_finance_user', name: 'finance_user', organization_id: ORG_A }, ], sys_position_permission_set: [ { id: 'bind_0', position_id: 'pos_0_closer', permission_set_id: 'ps_0_sales_user', organization_id: ORG_B }, - { id: 'bind_a', position_id: 'pos_a_closer', permission_set_id: 'ps_a_finance_user', organization_id: ORG_A }, ], sys_user_position: [ { id: 'up_1', user_id: 'usr_holder', position: 'closer', organization_id: ORG_A }, { id: 'up_2', user_id: 'usr_holder', position: 'night_shift', organization_id: ORG_A }, + { id: 'up_3', user_id: 'usr_holder', position: 'greeter', organization_id: ORG_A }, ], sys_business_unit_member: [], sys_user: [], @@ -984,7 +987,7 @@ describe('DelegatedAdminGate — a position is picked by organization, not by ar } return row[k] === v; }); - return { + const ql = { 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; @@ -993,6 +996,15 @@ describe('DelegatedAdminGate — a position is picked by organization, not by ar return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; }, } as any; + // The catalog: `closer` is NOT delegatable and distributes `finance_user`; + // `greeter` IS delegatable and distributes `sales_user`; no `night_shift`. + return bindTestSecurityCatalog(ql, { + positions: [ + { name: 'closer', permissionSets: ['finance_user'] }, + { name: 'greeter', delegatable: true, permissionSets: ['sales_user'] }, + ], + permissions: [{ name: 'sales_user' }, { name: 'finance_user' }], + }); } const makeGate = () => @@ -1008,15 +1020,19 @@ describe('DelegatedAdminGate — a position is picked by organization, not by ar context: { principal: 'holder', userId: 'usr_holder', tenantId: ORG_A }, }); - it("refuses a self-delegation org A never opted in to, whatever org B's first row says", async () => { + it('refuses a self-delegation the definition never opted in to, whatever a row says', async () => { await expect(selfDelegate('closer')).rejects.toThrow(/position 'closer' is not delegatable/); }); - it('an organization-less residue row does not make a position delegatable for a walled caller', async () => { + it('a position that exists only as a row is not delegatable', async () => { await expect(selfDelegate('night_shift')).rejects.toThrow(/position 'night_shift' is not delegatable/); }); - it("judges a delegated assignment by org A's own bindings, not org B's first row", async () => { + it('CONTROL: the definition opts in, a row saying otherwise does not refuse it', async () => { + await expect(selfDelegate('greeter')).resolves.toBeUndefined(); + }); + + it("judges a delegated assignment by the definition's permissionSets, not a junction row", async () => { await expect(makeGate().assert({ object: 'sys_user_position', operation: 'insert', diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 85e019e7cb6..cd23afebc5d 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -396,7 +396,7 @@ export class DelegatedAdminGate { switch (opCtx.object) { case 'sys_user_position': - return this.assertAssignmentWrite(opCtx, held, organizationId); + return this.assertAssignmentWrite(opCtx, held); case 'sys_user_permission_set': return this.assertDirectGrantWrite(opCtx, held); case 'sys_position_permission_set': @@ -764,11 +764,7 @@ export class DelegatedAdminGate { // ── sys_user_position: user ↔ position assignments ────────────────── - private async assertAssignmentWrite( - opCtx: any, - held: HeldScope[], - organizationId?: string, - ): Promise { + private async assertAssignmentWrite(opCtx: any, held: HeldScope[]): Promise { const targets = await this.materializeTargets(opCtx, 'sys_user_position'); for (const t of targets) { const buId = t.next?.business_unit_id ?? null; diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index 47673f34582..f38ca35784c 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -113,6 +113,12 @@ interface BootOptions { members?: boolean | 'unprovisioned'; } +/** The positions the fixture's assignment rows name, as their definitions. */ +const QA_POSITIONS = [ + { name: 'qa_rep', label: 'Rep' }, + { name: 'qa_lead', label: 'Lead' }, +]; + async function boot(opts: BootOptions = {}) { const withMembers = opts.members === undefined || opts.members === true; const engine = new ObjectQL(); @@ -143,9 +149,12 @@ async function boot(opts: BootOptions = {}) { const services: Record = { manifest: { register: vi.fn() }, objectql: engine, + // [ADR-0131 D3/D4] The two positions the rows name are declared, as the + // security catalog an assignment is judged against reads them. metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [MEMBER_DEFAULT, QA_ADMIN, QA_READ_ONLY_ON_GRANTS], + get: async (type: string, name: string) => + (type === 'position' ? QA_POSITIONS.find((p) => p.name === name) : engine.getSchema(name)) ?? null, + list: async (type: string) => (type === 'position' ? QA_POSITIONS : [MEMBER_DEFAULT, QA_ADMIN, QA_READ_ONLY_ON_GRANTS]), }, ...(opts.walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.ts index b2fbdc89864..647783c1b46 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.ts @@ -13,9 +13,10 @@ * resolve (D10). Until then the grant carries both, and this module is what * keeps them saying the same thing. Rows written before the column existed are * rewritten by the backfill stage, never here. The grant readers of - * `plugin-security` and `plugin-auth` key on the name ({@link grantSetNameOf}, - * {@link readGrantSetRows}); the authorization resolver in `@objectstack/core` - * still reads the id until its own stage switches it. + * `plugin-security` and `plugin-auth`, and the authorization resolver in + * `@objectstack/core`, key on the name ({@link grantSetNameOf}) and take the + * set itself from the security catalog (ADR-0131 D3), never from a + * `sys_permission_set` row. * * ## The invariant: the two columns never disagree * @@ -257,86 +258,6 @@ export function grantOrganizationOf(row: unknown): string | null { return String(value); } -/** The engine surface {@link readGrantSetRows} reads through. */ -export interface GrantSetRowEngine { - find(object: string, options: Record): Promise; -} - -/** - * [ADR-0131 D4] The `sys_permission_set` row each of `grants` reads its set - * from, BY NAME — for a reader that needs the row's own columns (the ADR-0049 - * `active` flag, a delegated-administration scope), not only the name. - * - * The catalog is still materialized per organization, so one name can carry - * several rows. A grant reads the row of its OWN organization, else the - * organization-less one — the same two rows its id may point at (a set row - * applies to a grant of its own organization, an organization-less row to - * every grant). An organization's rows are read with that organization - * threaded into the context, so the driver's tenant scope decides what is - * visible; an organization-less grant reads organization-less rows only. - * - * A grant that names nothing ({@link grantSetNameOf}) resolves to no row, and - * so does a name no applicable row carries. A read that fails throws: the - * caller decides what a failed read means for its own answer. - */ -export async function readGrantSetRows( - engine: GrantSetRowEngine, - grants: readonly unknown[], -): Promise<(grant: unknown) => Record | undefined> { - const namesByOrganization = new Map>(); - const allNames = new Set(); - for (const grant of grants) { - const name = grantSetNameOf(grant); - if (!name) continue; - allNames.add(name); - const organizationId = grantOrganizationOf(grant); - if (organizationId === null) continue; - let names = namesByOrganization.get(organizationId); - if (!names) { - names = new Set(); - namesByOrganization.set(organizationId, names); - } - names.add(name); - } - const rowsOf = (result: unknown): Array & { name: string }> => - (Array.isArray(result) ? result : []).filter( - (r): r is Record & { name: string } => - !!r && typeof r === 'object' && typeof (r as Record).name === 'string', - ); - const organizationLess = new Map>(); - if (allNames.size > 0) { - const names = [...allNames]; - const rows = rowsOf(await engine.find(PERMISSION_SET_CATALOG_OBJECT, { - where: { name: { $in: names }, organization_id: null }, - limit: names.length * 2 + 1, - context: { isSystem: true }, - })); - for (const row of rows) { - if (grantOrganizationOf(row) === null && !organizationLess.has(row.name)) organizationLess.set(row.name, row); - } - } - const own = new Map>>(); - for (const [organizationId, nameSet] of namesByOrganization) { - const names = [...nameSet]; - const rows = rowsOf(await engine.find(PERMISSION_SET_CATALOG_OBJECT, { - where: { name: { $in: names } }, - limit: names.length * 2 + 1, - context: { isSystem: true, tenantId: organizationId }, - })); - const byName = new Map>(); - for (const row of rows) { - if (grantOrganizationOf(row) === organizationId && !byName.has(row.name)) byName.set(row.name, row); - } - own.set(organizationId, byName); - } - return (grant: unknown) => { - const name = grantSetNameOf(grant); - if (!name) return undefined; - const organizationId = grantOrganizationOf(grant); - return (organizationId !== null ? own.get(organizationId)?.get(name) : undefined) ?? organizationLess.get(name); - }; -} - /** The set row an id points at, as far as a grant's name is concerned. */ interface CatalogRow { name: string; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d066380d98d..11d6e07814e 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1339,22 +1339,6 @@ export class SecurityPlugin implements Plugin { */ private catalogSetLoader?: (names: string[]) => Promise; - /** - * The organization a permission-set resolution runs in — the caller's own - * active organization, never a scan across all of them. - * - * Undefined for a `single`-posture caller (and for any context carrying no - * organization at all), which lands on the organization-less surface that - * posture correctly has. Both spellings are read for the same reason the - * sharing service reads both: `tenantId` is the declared envelope field and - * `organizationId` is the cast one, and a hand-built context may carry - * either. - */ - private callerOrganizationId(context: any): string | undefined { - const id = (context?.organizationId ?? context?.tenantId); - return typeof id === 'string' && id !== '' ? id : undefined; - } - /** * The permission-set loader for ONE caller. `undefined` when no engine is * wired, so `resolvePermissionSets` keeps its "no third source" branch. The From 8ae614365c6c5f5ddb09d529a31e07c1865b4fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:04:53 +0000 Subject: [PATCH 26/44] plugin-security: resolution, boot and assignment-refusal suites read the catalog Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../src/builtin-positions.boot.test.ts | 30 ++- .../permission-set-resolution-memo.test.ts | 35 ++-- .../src/position-catalog-refusal.test.ts | 52 ++++-- ...ve-permission-sets-for-context.pin.test.ts | 174 ++++++++---------- 4 files changed, 149 insertions(+), 142 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 144125ec4ee..5621c734116 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -397,15 +397,21 @@ async function tenantPositionWrites(engine: ObjectQL): Promise = { - 'assign a position no catalog row carries': { + 'assign a position the security catalog does not hold': { ok: false, code: 'VALIDATION_FAILED', fields: ['position:reference_not_found'], }, - 'control: create a position': { + 'control: create a position row': { ok: true, }, - 'control: assign it': { + 'assign the row-only position': { + ok: false, + code: 'VALIDATION_FAILED', + fields: ['position:reference_not_found'], + }, + 'control: assign a stack-declared position (no row needed)': { ok: true, }, 'delete the everyone row': { diff --git a/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts b/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts index 8db79d17317..af6cc1ec5f1 100644 --- a/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts @@ -44,7 +44,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -/** Resolvable from metadata, so only `custom_role` below reaches the db loader. */ +/** Resolvable from metadata, so only `custom_role` below reaches the catalog loader. */ const baselineSet: PermissionSet = { name: 'member_default', label: 'Member', @@ -52,8 +52,11 @@ const baselineSet: PermissionSet = { } as never; /** - * The db-loader harness. `find` is the SOLE observable: every call it records is - * one `sys_permission_set` round trip the deployment would really have made. + * The catalog-loader harness. The engine registry's by-name read of + * `custom_role` is the SOLE observable: every call it records is one catalog + * resolution of that set the deployment would really have made (ADR-0131 + * D3/D4 — the loader reads the security catalog, never a `sys_permission_set` + * row). */ const makeHarness = () => { const fields: Record = {}; @@ -69,11 +72,17 @@ const makeHarness = () => { ? [{ id: 'ps-1', name: 'custom_role', label: 'Custom', object_permissions: '{}' }] : [], ); + const getItem = vi.fn((type: string, name: string) => + type === 'permission' && name === 'custom_role' + ? { name: 'custom_role', label: 'Custom', objects: {} } + : undefined, + ); const ql = { registerMiddleware: (mw: never) => { if (!middleware) middleware = mw; }, getSchema: () => baseSchema, + registry: { getItem, listItems: () => [], isPackageDisabled: () => false }, find, findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }), }; @@ -94,21 +103,13 @@ const makeHarness = () => { ctx, find, /** - * How many reads THE DB LOADER has issued so far. - * - * Matched on the loader's own predicate shape (`name: { $in: [...] }`) - * rather than on the object name: several unrelated probes read - * `sys_permission_set` by single name on this path (the ADR-0095 - * auto-org-admin grant, the ADR-0090 audience-binding suggestions), and - * counting those would make the number mean "reads of a table" instead of - * "resolutions of this context" — the quantity under test. + * How many catalog set resolutions THE LOADER has issued so far — the + * registry's by-name reads of type `permission` (the baseline set answers + * from metadata and never reaches it), so a read of another type does not + * count: the number means "resolutions of this context", the quantity + * under test. */ - permissionSetReads: () => - find.mock.calls.filter((c) => { - if (c[0] !== 'sys_permission_set') return false; - const where = (c[1] as { where?: { name?: unknown } } | undefined)?.where; - return Array.isArray((where?.name as { $in?: unknown[] } | undefined)?.$in); - }).length, + permissionSetReads: () => getItem.mock.calls.filter((c) => c[0] === 'permission').length, run: async (opCtx: unknown) => { if (!middleware) throw new Error('middleware never registered'); await middleware(opCtx, async () => {}); 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 a8681ab8a59..2147861691a 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A `sys_user_position` write whose `position` names no `sys_position` row is - * refused — `400 VALIDATION_FAILED`, `reference_not_found` at `position` — + * A `sys_user_position` write whose `position` names no position the security + * catalog holds (ADR-0131 D3/D4) is refused — `400 VALIDATION_FAILED`, `reference_not_found` at `position` — * instead of answering 201 over an assignment that resolves to nothing. * * Measured on a REAL `ObjectQL` engine over a real SQL driver with the REAL @@ -312,6 +312,16 @@ describe('a position spelled as the catalog row ID is refused, and the account i }); describe('the accepted half — a catalog NAME, active or deactivated', () => { + it('a position declared only in the registry, with no sys_position row, is accepted', async () => { + const h = await boot(); + h.engine.registry.registerItem('position', { name: 'qa_registry_only', label: 'Registry only' } as any, 'name' as any, 'com.objectstack.qa.position-catalog-refusal'); + expect(await h.engine.find('sys_position', { where: { name: 'qa_registry_only' }, context: SYS })).toHaveLength(0); + const created = await h.engine.insert( + 'sys_user_position', { user_id: 'u_reg', position: 'qa_registry_only' }, { context: ADMIN } as any, + ); + expect(created).toMatchObject({ position: 'qa_registry_only' }); + }); + it('a catalog name is accepted and resolves: the holder reads rows', async () => { const h = await boot(); const created = await h.engine.insert( @@ -491,8 +501,8 @@ describe('a non-string position is judged by its string form', () => { expect([env.code, env.status], text).toEqual(['VALIDATION_FAILED', 400]); expect(env.fields[0], text).toMatchObject({ field: 'position', code: 'reference_not_found', value: text }); } - // A catalog row that really carries such a name is found, so the predicate holds. - await h.engine.insert('sys_position', { id: 'pos_lit', name: '{lit_pos}', label: 'Literal', active: true }, { context: SYS } as any); + // A catalog position that really carries such a name is found, so the predicate holds. + h.engine.registry.registerItem('position', { name: '{lit_pos}', label: 'Literal' } as any, 'name' as any, 'com.objectstack.qa.position-catalog-refusal'); const created = await h.engine.insert( 'sys_user_position', { user_id: 'u_tok_ok', position: '{lit_pos}' }, { context: ADMIN } as any, ); @@ -548,7 +558,11 @@ describe('scope — the stand-downs are declared, not accidental', () => { // A walled, two-organization posture // --------------------------------------------------------------------------- -describe("walled posture, two organizations — the predicate reads the WRITER's catalog", () => { +describe('walled posture, two organizations — the predicate reads the environment catalog, never an organization row', () => { + // [ADR-0131 D3/D4] Each organization holds a `sys_position` row; only + // `qa_a_own` also has a definition in the security catalog. The catalog is + // environment-level, so a row alone — whichever organization holds it — + // is not a position an assignment can take effect under. async function bootTwoOrgs() { const h = await boot({ walled: true }); await h.engine.insert('sys_position', @@ -557,6 +571,7 @@ describe("walled posture, two organizations — the predicate reads the WRITER's await h.engine.insert('sys_position', { id: 'pos_a_own', name: 'qa_a_own', label: 'A own', active: true }, { context: { isSystem: true, tenantId: 'org_a' } } as any); + h.engine.registry.registerItem('position', { name: 'qa_a_own', label: 'A own' } as any, 'name' as any, 'com.objectstack.qa.position-catalog-refusal'); return h; } const ORG_A_ADMIN = { ...ADMIN, tenantId: 'org_a' }; @@ -570,12 +585,11 @@ describe("walled posture, two organizations — the predicate reads the WRITER's expect(env.fields[0]).toMatchObject({ field: 'position', code: 'reference_not_found', value: 'nope_position' }); }); - it("a name only ANOTHER organization's catalog carries is REFUSED, answered exactly like a name no organization carries", async () => { - // REVERSED pin (#20297). The catalog is read the engine lookup probe's way, - // `{ ...context, isSystem: true }`: the writer's organization plus - // organization-less rows. org_b's `qa_b_only` is invisible from org_a, so - // the write is refused — and with the answer a name that exists nowhere - // gets, so the refusal is no oracle for "some other organization has it". + it("a name only an organization's ROW carries is REFUSED, answered exactly like a name nothing carries", async () => { + // [ADR-0131 D3/D4] org_b's `qa_b_only` row has no definition in the + // security catalog, so it grants nothing anywhere and the write is refused + // — with the answer a name that exists nowhere gets, so the refusal is no + // oracle for "some organization has a row of it" (#20297). const h = await bootTwoOrgs(); const foreign = envelopeOf(await refusalOf(() => h.engine.insert( 'sys_user_position', { user_id: 'u_wb', position: 'qa_b_only' }, { context: ORG_A_ADMIN } as any, @@ -610,7 +624,7 @@ describe("walled posture, two organizations — the predicate reads the WRITER's expect((await assignmentsOf(h, 'u_wm'))[0]?.position).toBe('qa_a_own'); }); - it("the writer's own organization's name is accepted (control for the scoped read)", async () => { + it('a name the catalog holds is accepted (control)', async () => { const h = await bootTwoOrgs(); await addMember(h, 'u_wo', 'org_a'); const created = await h.engine.insert( @@ -619,15 +633,13 @@ describe("walled posture, two organizations — the predicate reads the WRITER's expect(created).toMatchObject({ position: 'qa_a_own', organization_id: 'org_a' }); }); - it('the catalog read is scoped by the writer context — a context naming no organization reads every organization', async () => { - // A platform-level writer (no tenant in context) reads every organization, - // as the engine probe does. The full chain cannot carry one here: on the - // isolated posture the security middleware refuses a write with no active - // organization (403) before this refusal runs, so the read is pinned at - // the function, beside the org-bound reading of the same names. + it('the catalog read takes no organization — the same verdict for a writer in an organization and one in none', async () => { + // Pinned at the function: on the isolated posture the security middleware + // refuses a write with no active organization (403) before this refusal + // runs, so the full chain cannot carry a writer naming none. const h = await bootTwoOrgs(); const names = ['qa_b_only', 'qa_a_own', 'nope_position']; - expect(await namesWithoutCatalogRow({ ql: h.engine }, names, ADMIN)).toEqual(['nope_position']); + expect(await namesWithoutCatalogRow({ ql: h.engine }, names, ADMIN)).toEqual(['qa_b_only', 'nope_position']); expect(await namesWithoutCatalogRow({ ql: h.engine }, names, ORG_A_ADMIN)).toEqual(['qa_b_only', 'nope_position']); }); @@ -687,7 +699,7 @@ describe("walled posture, two organizations — the predicate reads the WRITER's expect(env.fields[0]).toMatchObject({ field: 'position', code: 'reference_not_found' }); expect(env.fields[0].message).toBe(positionNotInCatalogMessage('pos_b_only')); expect(env.fields[0].message).not.toContain('qa_b_only'); - // … while its own organization's row is named. + // … while its own organization's row, whose name the catalog holds, is named. const own = envelopeOf(await refusalOf(() => h.engine.insert( 'sys_user_position', { user_id: 'u_wc', position: 'pos_a_own' }, { context: ORG_A_ADMIN } as any, ))); diff --git a/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts b/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts index d843b19b132..4b073e59a41 100644 --- a/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts +++ b/packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts @@ -22,9 +22,9 @@ * `systemPermissions`, `tabPermissions` — because a consumer that must MERGE * the caller's grants cannot reach any of those four from * `resolvePermissionSetNames`. Each is asserted through the located handle, on - * BOTH authoring paths a set can arrive by (declared in metadata, and authored - * in `sys_permission_set` through the DB loader), because the loader is where - * a column silently goes missing. + * BOTH paths a set can arrive by (the metadata list, and the security + * catalog's definition through the plugin's catalog loader — ADR-0131 D3/D4), + * because a loader is where a column silently goes missing. */ import { describe, it, expect, vi } from 'vitest'; @@ -44,20 +44,24 @@ const MEMBER_DEFAULT: PermissionSet = { } as any; /** - * A DB-authored set, as it sits in `sys_permission_set` — snake_case columns, - * JSON-encoded payloads. This is the row shape the plugin's `dbLoader` parses, - * and the shape `/me/apps` reads `tab_permissions` off today in its own copy. + * An environment-authored set, as the security catalog holds it — the engine + * registry's definition (hydrated from `sys_metadata`), which the plugin's + * catalog loader hands back whole. */ -const SALES_MANAGER_ROW = { +const SALES_MANAGER: PermissionSet = { name: 'sales_manager', label: 'Sales Manager', - object_permissions: JSON.stringify({ deal: { allowRead: true, allowEdit: true } }), - field_permissions: JSON.stringify({ 'deal.amount': { readable: true, editable: true } }), - system_permissions: JSON.stringify(['setup.access']), - tab_permissions: JSON.stringify({ app_crm: 'visible' }), -}; + objects: { deal: { allowRead: true, allowEdit: true } }, + fields: { 'deal.amount': { readable: true, editable: true } }, + systemPermissions: ['setup.access'], + tabPermissions: { app_crm: 'visible' }, +} as any; -function bootPlugin(dbRows: Array> = []) { +/** The `sys_permission_set` reads for a set the resolution asked about — there must be none. */ +let permissionSetRowReads = 0; + +function bootPlugin(catalog: PermissionSet[] = [], dbRows: Array> = []) { + permissionSetRowReads = 0; const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields: { id: { name: 'id' }, amount: { name: 'amount' } } }; const ql: any = { registerMiddleware: () => {}, @@ -65,9 +69,19 @@ function bootPlugin(dbRows: Array> = []) { findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, find: async (object: string, query: any) => { if (object !== 'sys_permission_set') return []; + // Only a read asking for a set the resolution asked about counts: the + // boot's own background passes (the org-admin backfill, the baseline + // binder) read their own names and race the request. + if (/legacy_auditor|sales_manager/.test(JSON.stringify(query?.where ?? {}))) permissionSetRowReads += 1; const wanted: string[] = query?.where?.name?.$in ?? []; return dbRows.filter((r) => wanted.includes(String(r.name))); }, + // The engine registry the security catalog reads first (ADR-0131 D3). + registry: { + getItem: (type: string, name: string) => (type === 'permission' ? catalog.find((d) => d.name === name) : undefined), + listItems: (type: string) => (type === 'permission' ? catalog : []), + isPackageDisabled: () => false, + }, }; const metadata: any = { get: async (_type: string, name: string) => (name === 'deal' ? schema : null), @@ -92,10 +106,15 @@ function bootPlugin(dbRows: Array> = []) { * what makes the feature detection below the same expression the endpoints * will write. */ -async function locateSecurityService(dbRows: Array> = []): Promise> { - const { plugin, ctx } = bootPlugin(dbRows); +async function locateSecurityService( + catalog: PermissionSet[] = [], + dbRows: Array> = [], +): Promise> { + const { plugin, ctx } = bootPlugin(catalog, dbRows); await plugin.init(ctx); await plugin.start(ctx); + // Boot-time probes are not the resolution under test. + permissionSetRowReads = 0; const registered = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; return registered as Partial; } @@ -117,7 +136,7 @@ describe('[#7616] resolvePermissionSetsForContext is reachable through the servi }); it('is CALLABLE through that handle and returns the sets whole', async () => { - const svc = await locateSecurityService([SALES_MANAGER_ROW]); + const svc = await locateSecurityService([SALES_MANAGER]); const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', @@ -129,16 +148,15 @@ describe('[#7616] resolvePermissionSetsForContext is reachable through the servi const byName = new Map((sets ?? []).map((s) => [s.name, s])); expect([...byName.keys()].sort()).toEqual(['member_default', 'sales_manager']); - // All four columns the names surface cannot reach, on the DB-authored set — - // the path where a column goes missing, since the loader projects the row - // by hand. + // All four members the names surface cannot reach, on the catalog-loaded + // set — the path where a member could go missing. const dbAuthored: any = byName.get('sales_manager'); expect(dbAuthored.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); expect(dbAuthored.fields).toEqual({ 'deal.amount': { readable: true, editable: true } }); expect(dbAuthored.systemPermissions).toEqual(['setup.access']); - // The column `/me/apps` filters its app list with. Dropped by this loader - // until #7616 — which would have made the published contract false for - // every DB-authored set the moment a consumer trusted it. + // The member `/me/apps` filters its app list with (#7616): dropped, it + // would make the published contract false for every environment-authored + // set the moment a consumer trusted it. expect(dbAuthored.tabPermissions).toEqual({ app_crm: 'visible' }); // …and on the metadata-declared set, which arrives by the other path. @@ -149,7 +167,7 @@ describe('[#7616] resolvePermissionSetsForContext is reachable through the servi }); it('is the SAME resolution the names surface reports — baseline additive, no cliff', async () => { - const svc = await locateSecurityService([SALES_MANAGER_ROW]); + const svc = await locateSecurityService([SALES_MANAGER]); const context = { userId: 'u1', permissions: ['sales_manager'] } as any; const names = await svc.resolvePermissionSetNames?.(context); @@ -189,84 +207,48 @@ describe('[#7616] resolvePermissionSetsForContext is reachable through the servi }); /** - * [#11121 residue] An ORGANIZATION-LESS `sys_permission_set` row still grants. + * [ADR-0131 D3/D4] A set is the security catalog's definition; no + * `sys_permission_set` row is read. * - * #11121 made this loader tenant-scoped so two organizations holding a row for - * the same name stop answering each other's requests. Its comment promised the - * other half — "an organization-less leftover only where it does not [have its - * own]" — and the code read `.own` alone, which by - * `resolveOwnOrganizationRow`'s documented contract is NEVER a residue once an - * organization is supplied. That helper is written for SEEDERS, where refusing - * to see a residue as "already seeded" is the entire point; enforcement wants - * the opposite reading. - * - * The consequence was a silent revocation on upgrade: every walled deployment - * carrying pre-#11121 rows (or any row authored without a tenant — the admin - * UI, a hand-run seed) kept its `system_permissions` and `tab_permissions`, - * because that read is unscoped and by id, while its `object_permissions` and - * `admin_scope` stopped applying. One row, two enforcement planes, opposite - * verdicts, no signal at the moment of loss. + * The plugin's third source used to be a per-organization row loader (the + * caller's own row, else an organization-less one). The authorization + * resolver already took a set's body from the catalog and granted nothing + * through a row-only set (#15196 Q3 = A); a row fallback here applied that + * set's object map anyway — two sources with opposite verdicts on one set. + * The loader now reads the catalog, which is environment-level: the same + * definition for a caller in any organization or none. */ -const RESIDUE_ROW = { +const ROW_ONLY = { name: 'legacy_auditor', label: 'Legacy Auditor', - organization_id: null, - object_permissions: JSON.stringify({ deal: { allowRead: true, allowDelete: true } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - tab_permissions: JSON.stringify({}), -}; - -/** The SAME name, stamped with the caller's organization — this one must win. */ -const OWN_ROW = { - name: 'legacy_auditor', - label: 'Legacy Auditor (this organization)', organization_id: 'org_a', - object_permissions: JSON.stringify({ deal: { allowRead: true, allowDelete: false } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - tab_permissions: JSON.stringify({}), + object_permissions: JSON.stringify({ deal: { allowRead: true, allowDelete: true } }), + active: true, }; -describe('[#11121] the per-organization loader does not silently revoke organization-less grants', () => { - it('resolves an organization-LESS row for a caller who has an organization', async () => { - const svc = await locateSecurityService([RESIDUE_ROW]); - - const sets = await svc.resolvePermissionSetsForContext?.({ - userId: 'u1', - organizationId: 'org_a', - permissions: ['legacy_auditor'], - } as any); - - const found = (sets ?? []).find((s) => s.name === 'legacy_auditor'); - expect(found, 'the grant vanished for a caller in an organization — this is the silent revocation').toBeTruthy(); - // Whole, not merely present: the columns that went missing are the point. - expect((found as any)?.objects?.deal?.allowDelete).toBe(true); - }); - - it('still prefers THIS organization\'s own row when both exist — the cross-tenant bleed stays closed', async () => { - const svc = await locateSecurityService([RESIDUE_ROW, OWN_ROW]); - - const sets = await svc.resolvePermissionSetsForContext?.({ - userId: 'u1', - organizationId: 'org_a', - permissions: ['legacy_auditor'], - } as any); - - const found = (sets ?? []).find((s) => s.name === 'legacy_auditor'); - expect(found?.label).toBe('Legacy Auditor (this organization)'); - // The residue's broader grant must NOT leak in behind the own row. - expect((found as any)?.objects?.deal?.allowDelete).toBe(false); - }); - - it('resolves the same row for a caller with NO organization (single posture, unchanged)', async () => { - const svc = await locateSecurityService([RESIDUE_ROW]); - - const sets = await svc.resolvePermissionSetsForContext?.({ - userId: 'u1', - permissions: ['legacy_auditor'], - } as any); - - expect((sets ?? []).some((s) => s.name === 'legacy_auditor')).toBe(true); - }); +describe('[ADR-0131 D3/D4] the catalog loader reads definitions, never sys_permission_set rows', () => { + for (const [label, organizationId] of [['in an organization', 'org_a'], ['with no organization', undefined]] as const) { + it(`a set only a row carries resolves nothing for a caller ${label}, and no row is read`, async () => { + const svc = await locateSecurityService([], [ROW_ONLY, { ...ROW_ONLY, organization_id: null }]); + const sets = await svc.resolvePermissionSetsForContext?.({ + userId: 'u1', + ...(organizationId ? { organizationId } : {}), + permissions: ['legacy_auditor'], + } as any); + expect((sets ?? []).map((s) => s.name)).toEqual(['member_default']); + expect(permissionSetRowReads).toBe(0); + }); + + it(`CONTROL: the catalog's definition resolves whole for a caller ${label}`, async () => { + const svc = await locateSecurityService([SALES_MANAGER], [ROW_ONLY]); + const sets = await svc.resolvePermissionSetsForContext?.({ + userId: 'u1', + ...(organizationId ? { organizationId } : {}), + permissions: ['sales_manager'], + } as any); + const found: any = (sets ?? []).find((s) => s.name === 'sales_manager'); + expect(found?.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); + expect(permissionSetRowReads).toBe(0); + }); + } }); From ecfebd33dc66343363945949a7663c62fcd8995e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:13:30 +0000 Subject: [PATCH 27/44] plugin-security: the remaining suites declare their catalog and switch deactivation through the ledger Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../get-effective-object-permissions.test.ts | 27 ++- .../src/grant-readers-by-name.golden.test.ts | 19 ++- .../src/grant-readers-unnamed-grant.test.ts | 17 +- .../src/granted-by-writer-provenance.test.ts | 6 + .../src/orgless-position-name-fold.test.ts | 18 +- .../src/permission-set-active.test.ts | 159 ++++++++++-------- .../write-preview-field-gate-parity.test.ts | 3 + 7 files changed, 162 insertions(+), 87 deletions(-) diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index 844f14aa909..20fcbe7e64f 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -123,14 +123,33 @@ function bootPlugin( const schemas = opts.schemas ?? SCHEMAS; const permissionSetReads: string[][] = []; const registered: Array<(context: unknown) => Promise> = []; + // [ADR-0131 D3/D4] The sets are the security catalog's definitions — each + // row above as the registry holds it once saved through the metadata door — + // read live, so a set taken away is gone from the next resolution. + const definitionOf = (row: Record) => ({ + name: row.name, + label: row.label, + objects: JSON.parse(String(row.object_permissions ?? '{}')), + fields: JSON.parse(String(row.field_permissions ?? '{}')), + systemPermissions: JSON.parse(String(row.system_permissions ?? '[]')), + tabPermissions: JSON.parse(String(row.tab_permissions ?? '{}')), + }); const ql: any = { registerMiddleware: () => {}, - registry: { getAllObjects: () => Object.values(schemas) }, + registry: { + getAllObjects: () => Object.values(schemas), + getItem: (type: string, name: string) => { + if (type !== 'permission') return undefined; + permissionSetReads.push([name]); + const row = dbRows.find((r) => r.name === name); + return row ? definitionOf(row) : undefined; + }, + listItems: (type: string) => (type === 'permission' ? dbRows.map(definitionOf) : []), + isPackageDisabled: () => false, + }, getSchema: (name: string) => schemas[name] ?? null, find: async (object: string, query: any) => { - const tables: Record>> = { sys_permission_set: dbRows }; - if (object === 'sys_permission_set') permissionSetReads.push(query?.where?.name?.$in ?? []); - const rows = (tables[object] ?? []).filter((r) => matches(r, query?.where)); + const rows = ([] as Array>).filter((r) => matches(r, query?.where)); // Hold the caller's bound (`check:objectql-double-limit`). return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; }, 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 dec8f180673..5e0267e2c92 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 @@ -39,6 +39,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import type { PermissionSet } from '@objectstack/spec/security'; import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; @@ -51,6 +52,9 @@ import { SysPositionPermissionSet } from './objects/sys-position-permission-set. import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +/** The environment-authored set the member holds a grant of, as the security catalog declares it. */ +const LEGACY_REPORTS = { name: 'legacy_reports', label: 'Legacy reports' }; + const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; @@ -128,6 +132,8 @@ async function readersByPrincipal(posture: Posture): Promise type === 'permission' - ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + ? ([...defaultPermissionSets, QA_ADMIN, LEGACY_REPORTS] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null : engine.getSchema(name) ?? null, - list: async () => [...defaultPermissionSets, QA_ADMIN], + list: async () => [...defaultPermissionSets, QA_ADMIN, LEGACY_REPORTS], }, ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } @@ -186,7 +192,14 @@ async function readersByPrincipal(posture: Posture): Promise = [...defaultPermissionSets, { name: 'legacy_reports', label: 'Legacy' }]; const ORG = 'org_un'; const orgCtx = { isSystem: true, tenantId: ORG }; @@ -77,6 +79,8 @@ async function boot(): Promise { objects: [ SysUser, SysAccount, SysMember, SysOrganization, SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + // The activation ledger the explainer reads deactivation from (ADR-0131 D3). + SysMetadataActivation, ], } as any); await engine.syncSchemas(); @@ -84,9 +88,12 @@ async function boot(): Promise { const services: Record = { manifest: { register: vi.fn() }, objectql: engine, + // [ADR-0131 D3/D4] The security catalog: the platform's sets plus the + // environment-authored `legacy_reports` the explain case grants. metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [...defaultPermissionSets], + get: async (type: string, name: string) => + (type === 'permission' ? CATALOG_SETS.find((p) => p.name === name) : engine.getSchema(name)) ?? null, + list: async () => [...CATALOG_SETS], }, }; const ctx: any = { @@ -137,7 +144,11 @@ describe('[ADR-0131 D4] a grant that names nothing — explain', () => { const engine = await boot(); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_member', '2025-03-01T00:00:00.000Z'); - await engine.insert('sys_permission_set', { id: 'ps_off', name: 'legacy_reports', label: 'Legacy', active: false }, { context: SYS } as any); + await engine.insert('sys_permission_set', { id: 'ps_off', name: 'legacy_reports', label: 'Legacy' }, { context: SYS } as any); + // Switched off where the resolver and the explainer read it: the ledger. + await engine.insert('sys_metadata_activation', { + metadata_type: 'permission', name: 'legacy_reports', package_id: 'com.objectstack.qa.grant-readers-unnamed', active: false, + }, { context: SYS } as any); await engine.insert('sys_user_permission_set', [ { id: 'g_off', user_id: 'usr_member', permission_set_id: 'ps_off', organization_id: ORG }, { diff --git a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts index 6a6185b8c19..e4591e8beb1 100644 --- a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts +++ b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts @@ -163,6 +163,12 @@ async function boot(opts: { security: boolean } = { security: true }): Promise { expect(home.setNames).not.toContain('org_member'); }, 120_000); - it('CONTROL · the authoring organization\'s own member, with it active, does resolve its set — the set is resolvable at all', async () => { + // [ADR-0131 D3/D4, #15196 Q3 = A] REVERSED: the plugin no longer reads + // `sys_permission_set` rows at all, so a set an organization authored as a + // row with no catalog definition resolves for nobody — its own members, + // with it active, included. That is the walled-row loss the ruling accepts + // (loud at boot, promoted to the environment catalog by its operator); the + // global sets above resolve because the catalog declares them. + it('a set only an organization ROW carries resolves for nobody, its own active members included', async () => { const r = await rig(); const other = await resolve(r, USER_OTHER, ORG_OTHER); - expect(other.byName.get('org_member')?.systemPermissions).toEqual(['manage_metadata', 'probe.other_org_member']); - expect(other.ledger).toMatchObject({ viewAllRecords: true, modifyAllRecords: true }); + expect(other.byName.get('org_member')).toBeUndefined(); + expect(other.ledger).toBeNull(); + expect(other.loaderRows).toEqual([]); }, 120_000); }); diff --git a/packages/plugins/plugin-security/src/permission-set-active.test.ts b/packages/plugins/plugin-security/src/permission-set-active.test.ts index 673a48df5dd..9bc5df16606 100644 --- a/packages/plugins/plugin-security/src/permission-set-active.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-active.test.ts @@ -1,25 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#8613 / ADR-0049] `sys_permission_set.active`, at the DB loader. + * [#8613 / ADR-0049] A deactivated permission set, at the security plugin's + * own set resolution. * - * The dominant enforcement point for this flag is `resolveAuthzContext` in - * `@objectstack/core`, which drops a deactivated set before `context.permissions` - * is built. This loader is the SECOND reader, and it is not defence in depth - * that nothing reaches — the reachability case below is the reason it exists: + * The dominant enforcement point is `resolveUserAuthzGrants` in + * `@objectstack/core`, which drops a deactivated set before + * `context.permissions` is built. The plugin's resolution is the SECOND + * reader, and it is not defence in depth that nothing reaches — the + * reachability case below is the reason it exists: * * `resolvePermissionSetsForContext` requests `context.positions` as - * permission-set NAMES too (a position name is commonly reused as a set name, - * which the evaluator's own doc comment calls out). So an ACTIVE position - * whose name matches a DEACTIVATED `sys_permission_set` row arrives here with - * that name still standing — core filtered the position catalogue and the - * sets reached by id, and neither of those judged THIS row. + * permission-set NAMES too (a position name is commonly reused as a set + * name). So an ACTIVE position whose name matches a DEACTIVATED set arrives + * with that name still standing: core judged the position, not a set of + * the same name. * - * The filter runs in memory over rows the loader already fetched rather than as - * an `active: true` `where` predicate: `true` would also drop rows whose column - * is NULL (rows predating the field — a silent mass revocation on deployed - * data), and boolean `where` coercion differs per driver. `isRowActive` is the - * same predicate core and the break-glass guard use. + * [ADR-0131 D3, ADR-0126 §4] The switch is the activation ledger's + * (`sys_metadata_activation`, type `permission`), read through core's own + * `readDisabledCatalogNames` — the read the resolver takes — and the set's + * body is the security catalog's definition. A `sys_permission_set` row is not + * read at all, its `active` column included. */ import { describe, it, expect, vi } from 'vitest'; @@ -37,39 +38,46 @@ const MEMBER_DEFAULT: PermissionSet = { systemPermissions: [], } as any; -/** A DB-authored set, as it sits in `sys_permission_set`. `active` varies per case. */ -const salesManagerRow = (active: unknown) => ({ +/** An environment-authored set, as the security catalog holds it. */ +const SALES_MANAGER: PermissionSet = { name: 'sales_manager', label: 'Sales Manager', - object_permissions: JSON.stringify({ deal: { allowRead: true, allowEdit: true } }), - system_permissions: JSON.stringify(['setup.access']), - ...(active === undefined ? {} : { active }), -}); + objects: { deal: { allowRead: true, allowEdit: true } }, + systemPermissions: ['setup.access'], +} as any; -/** - * Resolve the security service the way a cross-package consumer does. The fake - * engine matches on `where.name.$in` ONLY — deliberately, because that is the - * predicate the loader has always sent. A filter expressed as an extra `where` - * key would be invisible to this fixture (and to any driver that ignores an - * unknown column), so the assertion below measures the product's own judgment. - */ -async function locateSecurityService( - dbRows: Array>, -): Promise> { +interface Fixture { + /** `sys_metadata_activation` rows. */ + ledger?: Array>; + /** `sys_permission_set` rows — present to prove they are not read. */ + rows?: Array>; +} + +/** Resolve the security service the way a cross-package consumer does, and count the ledger reads. */ +async function locate(fixture: Fixture = {}): Promise<{ svc: Partial; ledgerReads: () => number }> { const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields: { id: { name: 'id' }, amount: { name: 'amount' } }, }; + let ledgerReads = 0; const ql: any = { registerMiddleware: () => {}, getSchema: (name: string) => (name === 'deal' ? schema : null), findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, find: async (object: string, query: any) => { - if (object !== 'sys_permission_set') return []; - const wanted: string[] = query?.where?.name?.$in ?? []; - return dbRows.filter((r) => wanted.includes(String(r.name))); + if (object === 'sys_permission_set') return fixture.rows ?? []; + if (object !== 'sys_metadata_activation') return []; + ledgerReads += 1; + const types: string[] = query?.where?.metadata_type?.$in ?? []; + const names: string[] = query?.where?.name?.$in ?? []; + return (fixture.ledger ?? []).filter((r) => types.includes(String(r.metadata_type)) && names.includes(String(r.name))); + }, + registry: { + getItem: (type: string, name: string) => (type === 'permission' && name === SALES_MANAGER.name ? SALES_MANAGER : undefined), + listItems: (type: string) => (type === 'permission' ? [SALES_MANAGER] : []), + isPackageDisabled: () => false, }, }; const metadata: any = { @@ -88,64 +96,71 @@ async function locateSecurityService( const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' } as any); await plugin.init(ctx); await plugin.start(ctx); - return ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; + ledgerReads = 0; // boot-time reads are not the resolution under test + return { + svc: ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1], + ledgerReads: () => ledgerReads, + }; } -const namesFor = async ( - rows: Array>, - context: Record, -): Promise => { - const svc = await locateSecurityService(rows); +const namesFor = async (fixture: Fixture, context: Record): Promise => { + const { svc } = await locate(fixture); const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', ...context } as any); return (sets ?? []).map((s) => s.name); }; -describe('[#8613] the DB loader drops DEACTIVATED permission sets', () => { - it('a deactivated set requested by name resolves to nothing', async () => { - const names = await namesFor([salesManagerRow(false)], { permissions: ['sales_manager'] }); +const switchedOff = (active: unknown) => ({ metadata_type: 'permission', name: 'sales_manager', active }); + +describe('[#8613] a position name reaching a set the activation ledger switched off', () => { + it('THE REACHABILITY CASE: the folded name resolves nothing; the baseline still applies', async () => { + const names = await namesFor({ ledger: [switchedOff(false)] }, { positions: ['sales_manager'], permissions: [] }); expect(names).not.toContain('sales_manager'); - // The baseline still applies — deactivating one set is not a blanket denial. expect(names).toContain('member_default'); }); - it('an ACTIVE set still resolves, whole', async () => { - const svc = await locateSecurityService([salesManagerRow(true)]); - const sets = await svc.resolvePermissionSetsForContext?.({ - userId: 'u1', - permissions: ['sales_manager'], - } as any); + it('…and the same request with no ledger row does resolve, whole — the case is real, not vacuous', async () => { + const { svc } = await locate(); + const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: ['sales_manager'], permissions: [] } as any); const found: any = (sets ?? []).find((s) => s.name === 'sales_manager'); - expect(found).toBeDefined(); - expect(found.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); - expect(found.systemPermissions).toEqual(['setup.access']); + expect(found?.objects).toEqual({ deal: { allowRead: true, allowEdit: true } }); + expect(found?.systemPermissions).toEqual(['setup.access']); }); - it('a row with NO `active` column still resolves — deployed rows are not mass-revoked', async () => { - const names = await namesFor([salesManagerRow(undefined)], { permissions: ['sales_manager'] }); - expect(names).toContain('sales_manager'); + it('the 0/1 storage shape of the ledger is judged too, not only a literal `false`', async () => { + expect(await namesFor({ ledger: [switchedOff(0)] }, { positions: ['sales_manager'] })).not.toContain('sales_manager'); + expect(await namesFor({ ledger: [switchedOff(1)] }, { positions: ['sales_manager'] })).toContain('sales_manager'); }); - it('the 0/1 storage shape is judged too, not only a literal `false`', async () => { - expect(await namesFor([salesManagerRow(0)], { permissions: ['sales_manager'] })).not.toContain( - 'sales_manager', + it("a ledger row of ANOTHER type under the same name switches nothing off (a position's row is not the set's)", async () => { + const names = await namesFor( + { ledger: [{ metadata_type: 'position', name: 'sales_manager', active: false }] }, + { positions: ['sales_manager'] }, ); - expect(await namesFor([salesManagerRow(1)], { permissions: ['sales_manager'] })).toContain( - 'sales_manager', + expect(names).toContain('sales_manager'); + }); + + it("a catalog ROW's `active: false` is not read — only the ledger switches a set off", async () => { + const names = await namesFor( + { rows: [{ name: 'sales_manager', active: false }] }, + { positions: ['sales_manager'] }, ); + expect(names).toContain('sales_manager'); }); +}); - it('THE REACHABILITY CASE: a POSITION name reaching a deactivated set of the same name', async () => { - // Core cannot judge this row: it filtered the `sys_position` catalogue and - // the sets it reached by id, and this name arrives as a POSITION the caller - // legitimately holds. Without this loader's filter the deactivated set - // would grant `deal` edit rights through the name-reuse path — the wall - // that looks enforced and is not. - const names = await namesFor([salesManagerRow(false)], { positions: ['sales_manager'] }); - expect(names).not.toContain('sales_manager'); +describe('[ADR-0049] names that arrive already judged are not judged again', () => { + it('a set the resolver granted (`context.permissions`) issues no ledger read', async () => { + const { svc, ledgerReads } = await locate({ ledger: [switchedOff(false)] }); + const sets = await svc.resolvePermissionSetsForContext?.({ userId: 'u1', permissions: ['sales_manager'] } as any); + // The resolver dropped a switched-off set before building `permissions`; + // a name it hands over is in effect, so no second read is spent on it. + expect((sets ?? []).map((s) => s.name)).toContain('sales_manager'); + expect(ledgerReads()).toBe(0); }); - it('…and the same request with the set ACTIVE does resolve — the case is real, not vacuous', async () => { - const names = await namesFor([salesManagerRow(true)], { positions: ['sales_manager'] }); - expect(names).toContain('sales_manager'); + it('a request with no folded collision issues no ledger read', async () => { + const { svc, ledgerReads } = await locate(); + await svc.resolvePermissionSetsForContext?.({ userId: 'u1', positions: ['everyone', 'org_member'], permissions: [] } as any); + expect(ledgerReads()).toBe(0); }); }); diff --git a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts index 0bc3244267e..8bf66a26a47 100644 --- a/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts +++ b/packages/plugins/plugin-security/src/write-preview-field-gate-parity.test.ts @@ -321,6 +321,9 @@ async function boot(sets: PermissionSet[], opts: { orgScoping?: boolean } = {}) engine.registry.registerObject(ENG_LOG_AMENDABLE as any, 'test-package'); engine.registry.registerObject(USER_POSITION as any, 'test-package'); engine.registry.registerObject(ORG_TASK as any, 'test-package'); + // [ADR-0131 D3/D4] The position the assignment rows name, declared in the + // security catalog the assignment refusal reads. + engine.registry.registerItem('position', { name: 'sales', label: 'Sales' } as any, 'name' as any, 'test-package'); d.storeFor('crm_account').set('acc_p', { id: 'acc_p', name: 'P', type: 'partner' }); d.storeFor('crm_account').set('acc_d', { id: 'acc_d', name: 'D', type: 'direct' }); From 7cfa4404a916514c070d5a4fd5bd4718bb1eda8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:16:59 +0000 Subject: [PATCH 28/44] plugin-security: pin explain/enforce agreement on the ledger and the one-holder refusal (ruling Q (a)) Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../src/explain-engine.test.ts | 69 +++++++++++++++-- .../src/position-write-through.test.ts | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 743f5d43c80..9170e8313dc 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, ledgerFromTables } from './__tests__/security-catalog.testkit.js'; +import { bindCatalogFromTables, bindTestSecurityCatalog, 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,10 +873,10 @@ function makeGrantQl(tables: Rows) { return bindCatalogFromTables({ async find(object: string, opts: any) { const where = opts?.where ?? {}; - // [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). + // [ADR-0131 D3] The resolver AND the explainer's "deactivated" + // annotation read deactivation from the activation ledger: a fixture's + // `active: false` rows reach both as the ledger rows the upgrade ceremony + // converts them to, unless the fixture states its ledger itself. 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}`); @@ -1087,6 +1087,65 @@ describe('buildContextForUser', () => { ]); }); + // [ADR-0131 D3, ADR-0126 §4] Explain and enforce read ONE switch: the + // activation ledger, through the resolver's own `readDisabledCatalogNames`. + // Each case below states the ledger and the rows apart, so the two disagree, + // and the explainer must side with what the resolver enforces. + it('a set and a position the LEDGER switched off are reported deactivated, whatever their rows say', async () => { + const ql = makeGrantQl({ + sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: true }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: true }], + sys_metadata_activation: [ + { metadata_type: 'position', name: 'field_auditor', active: false }, + { metadata_type: 'permission', name: 'crm_full', active: 0 }, + ], + }); + const ctx = await buildContextForUser(ql, 'u2', NOW); + // Enforcement dropped both… + expect(ctx.positions).toEqual(['everyone']); + expect(ctx.permissions).toEqual([]); + // …and explain says why, from the same ledger. + expect(ctx.droppedGrants).toEqual([ + { kind: 'position', name: 'field_auditor', state: 'deactivated' }, + { kind: 'permission_set', name: 'crm_full', state: 'deactivated' }, + ]); + }); + + it("a catalog ROW's `active: false` with no ledger row is in effect, and explain reports nothing dropped", async () => { + const ql = makeGrantQl({ + sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], + sys_metadata_activation: [], + }); + const ctx = await buildContextForUser(ql, 'u2', NOW); + expect(ctx.positions).toEqual(['field_auditor', 'everyone']); + expect(ctx.permissions).toEqual(['crm_full']); + expect(ctx.droppedGrants).toEqual([]); + }); + + it('a grant naming a set the catalog does not hold is reported neither expired nor deactivated — there is no set to lose', async () => { + const ql = bindTestSecurityCatalog({ + async find(object: string, opts: any) { + const rows: Rows = { + sys_user_permission_set: [ + { user_id: 'u2', permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, + ], + sys_metadata_activation: [{ metadata_type: 'permission', name: 'gone_off', active: false }], + }; + const where = opts?.where ?? {}; + return (rows[object] ?? []).filter((row) => Object.entries(where).every(([k, c]) => + c && typeof c === 'object' && '$in' in (c as any) ? ((c as any).$in as unknown[]).includes(row[k]) : row[k] === c)); + }, + }, { permissions: [], positions: [] }); + const ctx = await buildContextForUser(ql, 'u2', NOW); + expect(ctx.droppedGrants).toEqual([]); + }); + it('an absent `active` column keeps granting and is NOT reported — absent is ACTIVE (ADR-0049)', async () => { const qlAbsent = makeGrantQl({ sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], 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 0cf67903d2e..77bb61ec34c 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -43,6 +43,7 @@ import { SysMetadataAuditObject, } from '@objectstack/metadata-core'; import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { resolveThrownHttpError } from '@objectstack/types'; import type { PermissionSet } from '@objectstack/spec/security'; import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; @@ -622,6 +623,80 @@ describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create o }); }); +describe('[ruling Q (a)] one holder per position name per deployment — a create or a rename into a name the environment catalog holds is refused', () => { + /** + * The engine's duplicate envelope (`DuplicateRecordError`; the REST layer + * reports it as `409 UNIQUE_VIOLATION`) — the one a second row of a name in + * ONE organization already answers, pinned as the control below. + */ + const DUPLICATE = { code: 'DUPLICATE_RECORD', status: 409 }; + const httpEnvelope = (e: unknown) => { + const r = resolveThrownHttpError(e); + return { code: r.code, status: r.status }; + }; + const OTHER_ORG = 'org_pw_other'; + /** An administrator of a SECOND organization of the same `single` deployment. */ + const otherAdmin = (b: Booted) => ({ ...b.admin, tenantId: OTHER_ORG, accessible_org_ids: [OTHER_ORG] }); + const bootTwo = async () => { + const b = await boot(); + await b.engine.insert('sys_organization', { id: OTHER_ORG, name: 'PW Other', slug: 'pw-other' }, { context: SYS } as any); + return b; + }; + + it("a second organization's create of a name the first organization's position holds is refused 409; the first definition is untouched", async () => { + const b = await bootTwo(); + await create(b, { name: 'shift_lead', label: 'Shift lead', description: 'First holder' }); + const refusal = await refusalOf(b.engine.insert('sys_position', { name: 'shift_lead', label: 'Theirs' }, { context: otherAdmin(b) } as any)); + expect(httpEnvelope(refusal)).toEqual(DUPLICATE); + // One dialect: a second holder in the SAME organization answers the same. + expect(httpEnvelope(await refusalOf(create(b, { name: 'shift_lead', label: 'Again' })))).toEqual(DUPLICATE); + expect((await b.rows('shift_lead')).map((r) => r.label)).toEqual(['Shift lead']); + expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ + { name: 'shift_lead', label: 'Shift lead', description: 'First holder', delegatable: false }, + ]); + }); + + it("a create of a name saved only through the metadata door is refused 409; the definition keeps its permissionSets", async () => { + const b = await boot(); + await b.protocol.saveMetaItem({ + type: 'position', name: 'door_lead', item: { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, + }); + const refusal = await refusalOf(create(b, { name: 'door_lead', label: 'Setup copy' })); + expect(httpEnvelope(refusal)).toEqual(DUPLICATE); + expect(await b.rows('door_lead')).toEqual([]); + expect((await b.envRows('door_lead')).map((r) => r.body)).toEqual([ + { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, + ]); + }); + + it('a rename into a name the environment catalog holds is refused 409; the row keeps its old name', async () => { + const b = await bootTwo(); + await create(b, { name: 'dock_chief', label: 'Dock chief' }); + const mine: any = await b.engine.insert('sys_position', { name: 'yard_chief', label: 'Yard chief' }, { context: otherAdmin(b) } as any); + const refusal = await refusalOf(b.engine.update('sys_position', { id: mine.id, name: 'dock_chief' }, { context: otherAdmin(b) } as any)); + expect(httpEnvelope(refusal)).toEqual(DUPLICATE); + expect((await b.rows('yard_chief')).map((r) => r.id)).toEqual([mine.id]); + expect((await b.envRows('dock_chief')).map((r) => r.body.label)).toEqual(['Dock chief']); + }); + + it('control: a name no definition holds is created, and an edit that keeps a held name is no second holder', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'free_lead', label: 'Free lead' }); + await patch(b, created.id, { label: 'Free lead (relabelled)' }); + expect((await b.envRows('free_lead')).map((r) => r.body.label)).toEqual(['Free lead (relabelled)']); + }); + + it('a catalog that cannot be read refuses the create — accepting would merge blind', async () => { + const b = await boot(); + const unreadable = Object.assign(new Error('catalog unreadable'), { code: 'AUTHZ_STORE_UNAVAILABLE', status: 503 }); + vi.spyOn((b.engine as any).registry, 'getItem').mockImplementation(() => { throw unreadable; }); + const refusal: any = await refusalOf(create(b, { name: 'blind_lead', label: 'Blind lead' })); + expect(refusal).not.toBeNull(); + vi.restoreAllMocks(); + expect(await b.rows('blind_lead')).toEqual([]); + }); +}); + describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => { it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => { const b = await boot(); From 96fb3e9811b91049fbff72abc74893d707a9e8af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:18:29 +0000 Subject: [PATCH 29/44] 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 57891912f8557b3a62e8705e82c2e67ef8623711 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:27:44 +0000 Subject: [PATCH 30/44] core: the catalog read's docs name the activation ledger as the deactivation switch Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- packages/core/src/security/index.ts | 3 ++- packages/core/src/security/security-catalog.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index edbfa2b8e1b..d32a892f2f2 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -236,7 +236,8 @@ export { isRowActive, type ActivatableRow } from './row-active.js'; // ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions, // permission sets, capabilities) over the engine registry and the metadata // service. It says a definition EXISTS under a name, never that it is in -// effect: the row `active` flag above stays the authority for that. +// effect: the activation ledger is the authority for that +// (`readDisabledCatalogNames`, exported with the resolver below). export { createSecurityCatalogReader, bindSecurityCatalogReader, diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts index 5fc24ee51be..368e25846f3 100644 --- a/packages/core/src/security/security-catalog.ts +++ b/packages/core/src/security/security-catalog.ts @@ -67,13 +67,14 @@ * * ## What this read does NOT answer * - * - ⛔ **Whether an item is in effect.** Deactivation lives on the catalog - * ROW (`sys_position.active`, `sys_permission_set.active`; standing ruling: - * the row flag stays authoritative), and no definition carries it. An entry - * here says a definition EXISTS under that name — never that it grants. A - * 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. + * - ⛔ **Whether an item is in effect.** No definition carries the switch: + * deactivation is the activation ledger's (`sys_metadata_activation`, one + * row per `(metadata_type, name)`; ADR-0126 §3 regime C as ADR-0131 D6 + * amends it), read through `readDisabledCatalogNames` + * (`resolve-authz-context.ts`) — never a catalog row's `active` column. An + * entry here says a definition EXISTS under that name — never that it + * grants. A caller that reads a definition and skips the ledger would let a + * deactivated set grant again: the one widening this read must never open. * - **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 From fb3861c93704656b17e71c832091887d4ea510f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:37:13 +0000 Subject: [PATCH 31/44] plugin-security: an unused fake-engine parameter is spelled as unused Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../src/get-effective-object-permissions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index 20fcbe7e64f..a901abe30d9 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -148,7 +148,7 @@ function bootPlugin( isPackageDisabled: () => false, }, getSchema: (name: string) => schemas[name] ?? null, - find: async (object: string, query: any) => { + find: async (_object: string, query: any) => { const rows = ([] as Array>).filter((r) => matches(r, query?.where)); // Hold the caller's bound (`check:objectql-double-limit`). return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; From cce1497e41e3ea5c0428311e9f15f67e0aa3e9be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:39:47 +0000 Subject: [PATCH 32/44] plugin-security: the one-holder refusal answers 409 UNIQUE_VIOLATION through an in-package error, not an objectql import Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../plugin-security/src/errors.test.ts | 1 + .../plugins/plugin-security/src/errors.ts | 31 +++++++++++++++++++ .../src/position-write-through.test.ts | 14 ++++----- .../src/position-write-through.ts | 20 +++--------- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/plugins/plugin-security/src/errors.test.ts b/packages/plugins/plugin-security/src/errors.test.ts index 2f5acfb507b..70f3c1be9ae 100644 --- a/packages/plugins/plugin-security/src/errors.test.ts +++ b/packages/plugins/plugin-security/src/errors.test.ts @@ -42,6 +42,7 @@ const FLOOR = [ 'MaskedValueWriteError', 'ExplainObjectNotFoundError', 'PermissionSetNameConflictError', + 'PositionNameConflictError', ]; /** diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index 7195b026c41..70006b01035 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -410,6 +410,37 @@ export class PermissionSetNameConflictError extends Error { } } +/** + * [ruling Q (a), ADR-0131 D3] A Setup create of a position, or a rename into a + * name, that the security catalog already holds a definition of under the + * `single` posture: a second holder of one position name per deployment → + * `409 UNIQUE_VIOLATION` (`position-write-through.ts`). + * + * The wire identity is the one this platform already answers for a second + * `sys_position` row of a name in ONE organization (the engine's unique index, + * reported as `409 UNIQUE_VIOLATION`), and the one + * {@link PermissionSetNameConflictError} answers for the sibling catalog — one + * condition, one envelope, whichever layer catches it. It stamps the same + * named constants, so it adds no second emitter spelling of the code. + */ +export class PositionNameConflictError extends Error { + readonly code = PERMISSION_SET_NAME_CONFLICT_CODE; + readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS; + readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS; + /** The position name that is already held. */ + readonly positionName: string; + constructor(positionName: string, holder?: string) { + super( + `[Security] a position named '${positionName}' already exists in this deployment` + + `${holder ? ` (declared by '${holder}')` : ''}: under the single tenancy posture a position name ` + + 'is unique per deployment, and two holders of one name are never merged. Choose another name, ' + + 'or edit the existing position.', + ); + this.name = 'PositionNameConflictError'; + this.positionName = positionName; + } +} + export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; 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 77bb61ec34c..6de62903f68 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -43,7 +43,7 @@ import { SysMetadataAuditObject, } from '@objectstack/metadata-core'; import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { resolveThrownHttpError } from '@objectstack/types'; +import { mapDataError } from '@objectstack/types'; import type { PermissionSet } from '@objectstack/spec/security'; import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; @@ -625,14 +625,14 @@ describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create o describe('[ruling Q (a)] one holder per position name per deployment — a create or a rename into a name the environment catalog holds is refused', () => { /** - * The engine's duplicate envelope (`DuplicateRecordError`; the REST layer - * reports it as `409 UNIQUE_VIOLATION`) — the one a second row of a name in - * ONE organization already answers, pinned as the control below. + * The wire envelope the REST door answers (`mapDataError`) — the one a + * second row of a name in ONE organization already answers, pinned as the + * control below. */ - const DUPLICATE = { code: 'DUPLICATE_RECORD', status: 409 }; + const DUPLICATE = { code: 'UNIQUE_VIOLATION', status: 409 }; const httpEnvelope = (e: unknown) => { - const r = resolveThrownHttpError(e); - return { code: r.code, status: r.status }; + const r = mapDataError(e, 'sys_position'); + return { code: (r.body as { code?: unknown }).code, status: r.status }; }; const OTHER_ORG = 'org_pw_other'; /** An administrator of a SECOND organization of the same `single` deployment. */ diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index f65d45d8d06..f97c47663d6 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -79,10 +79,9 @@ * refused, and the row write is undone. Before this, the definition save that * follows overwrote the existing definition with this row's label and * description and dropped its `permissionSets`: two holders silently merged - * into one. The refusal is the engine's own unique-violation envelope - * (`DuplicateRecordError`, `409 UNIQUE_VIOLATION` over HTTP, on `name`) — the - * answer a second row of the same name in one organization already gets — so - * an author meets one dialect for "this name is taken". The read is the + * into one. The refusal is `409 UNIQUE_VIOLATION` (`PositionNameConflictError`) + * — the answer a second row of the same name in one organization already gets + * over HTTP — so an author meets one dialect for "this name is taken". The read is the * catalog the resolver grants through (`securityCatalogReaderOf`), asked * before any definition is saved; a catalog that cannot be read refuses (the * row goes and that failure is the answer), because accepting would merge @@ -123,10 +122,10 @@ */ import { securityCatalogReaderOf } from '@objectstack/core'; -import { DuplicateRecordError } from '@objectstack/objectql'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { PositionSchema } from '@objectstack/spec/identity'; +import { PositionNameConflictError } from './errors.js'; /** The catalog object this module is registered on. */ export const POSITION_OBJECT = 'sys_position'; @@ -312,16 +311,7 @@ async function environmentHeldPositionNameRefusal(ql: any, name: unknown): Promi if (!catalog) return null; const held = await catalog.resolve(POSITION_METADATA_TYPE, name); if (!held) return null; - return new DuplicateRecordError( - POSITION_OBJECT, - new Error( - `position '${name}' already has a definition in this deployment's security catalog` - + `${held.packageId ? ` (declared by '${held.packageId}')` : ''}; under the single tenancy posture a ` - + 'position name is unique per deployment, and two holders of one name are never merged. Choose ' - + 'another name, or edit the existing position.', - ), - 'name', - ); + return new PositionNameConflictError(name, held.packageId); } const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); From 618633823b864f55e6a44896aeb54d225b9d2657 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:53:19 +0000 Subject: [PATCH 33/44] plugin-security: the test doubles hold the double-limit contract; permission-set-active leaves the baseline Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../src/explain-engine.test.ts | 25 ++++++++----------- .../get-effective-object-permissions.test.ts | 20 ++------------- scripts/objectql-double-limit.baseline.json | 3 --- 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 9170e8313dc..87d23d8cb9c 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, bindTestSecurityCatalog, ledgerFromTables } 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: @@ -1128,20 +1128,15 @@ describe('buildContextForUser', () => { }); it('a grant naming a set the catalog does not hold is reported neither expired nor deactivated — there is no set to lose', async () => { - const ql = bindTestSecurityCatalog({ - async find(object: string, opts: any) { - const rows: Rows = { - sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, - { user_id: 'u2', permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, - ], - sys_metadata_activation: [{ metadata_type: 'permission', name: 'gone_off', active: false }], - }; - const where = opts?.where ?? {}; - return (rows[object] ?? []).filter((row) => Object.entries(where).every(([k, c]) => - c && typeof c === 'object' && '$in' in (c as any) ? ((c as any).$in as unknown[]).includes(row[k]) : row[k] === c)); - }, - }, { permissions: [], positions: [] }); + // No `sys_permission_set` row, so the catalog these tables convert to holds + // neither name; the ledger switches one of them off all the same. + const ql = makeGrantQl({ + sys_user_permission_set: [ + { user_id: 'u2', permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, + ], + sys_metadata_activation: [{ metadata_type: 'permission', name: 'gone_off', active: false }], + }); const ctx = await buildContextForUser(ql, 'u2', NOW); expect(ctx.droppedGrants).toEqual([]); }); diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index a901abe30d9..c8714ba0bc6 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -103,19 +103,6 @@ const SCHEMAS: Record = { report: { name: 'report', label: 'Report', enable: { apiMethods: ['get', 'list'] }, fields: { id: { name: 'id' } } }, }; -/** `where` matcher: scalar equality plus the `$in` form the resolver sends; any other operator REFUSES. */ -function matches(row: Record, where: Record | undefined): boolean { - return Object.entries(where ?? {}).every(([key, cond]) => { - if (key.startsWith('$')) throw new Error(`fake engine: unsupported operator ${key}`); - const value = row[key] ?? null; - if (cond && typeof cond === 'object' && Array.isArray((cond as { $in?: unknown }).$in)) { - return ((cond as { $in: unknown[] }).$in).includes(value); - } - if (cond && typeof cond === 'object') throw new Error(`fake engine: unsupported condition on ${key}`); - return value === (cond ?? null); - }); -} - function bootPlugin( opts: { dbRows?: Array>; engineSeam?: boolean; schemas?: Record } = {}, ) { @@ -148,11 +135,8 @@ function bootPlugin( isPackageDisabled: () => false, }, getSchema: (name: string) => schemas[name] ?? null, - find: async (_object: string, query: any) => { - const rows = ([] as Array>).filter((r) => matches(r, query?.where)); - // Hold the caller's bound (`check:objectql-double-limit`). - return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; - }, + // No table answers here: every set is the catalog's (above). + find: async () => [], }; if (opts.engineSeam !== false) { ql.registerEffectiveObjectPermissionsResolver = (fn: (context: unknown) => Promise) => registered.push(fn); diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 4349aff2929..bd41848d76d 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -618,9 +618,6 @@ "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/permission-set-active.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/permission-set-drift.test.ts": { "unjudged": 1 }, From fb2855fb34cc9d40e3af019499b8b93a4bed44e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:56:18 +0000 Subject: [PATCH 34/44] 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 b609736b1d78f28f3ea1d3989c7ac103d6a112e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:11:48 +0000 Subject: [PATCH 35/44] plugin-security: the catalog gate test's where matcher refuses what it does not implement Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../plugin-security/src/delegated-admin-gate-catalog.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts index 7256ccd7e8c..0e0092aee66 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-catalog.test.ts @@ -80,7 +80,9 @@ function makeQl(tables: Record = {}) { }; const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + if (v && typeof v === 'object') throw new Error(`fake driver: unsupported condition on ${k}`); return row[k] === v; }); return { From 2c3a59889107086ffeeff72e97dbcd31dc6e6507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:26:44 +0000 Subject: [PATCH 36/44] 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 5a960f8ae833b104886449cbc956132dc22fba8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:12:35 +0000 Subject: [PATCH 37/44] dogfood: the delegation and scope-fallback fixtures declare their position and set through the metadata door Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .../test/delegation-of-duty.dogfood.test.ts | 16 ++++++++---- ...wcase-scope-depth-fallback.dogfood.test.ts | 26 +++++++++---------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts index 22ad78f88a5..f77aeb41cf1 100644 --- a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -65,7 +65,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { fallbackPermissionSet: 'deleg_member', }), }); - await stack.signIn(); + const adminToken = await stack.signIn(); delegatorToken = await stack.signUp(DELEGATOR); await stack.signUp(DELEGATE); ql = await stack.kernel.getServiceAsync('objectql'); @@ -81,10 +81,16 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); - // A delegatable position + a non-delegatable one (system inserts sidestep - // authoring rules). The delegator DIRECTLY holds the delegatable one. - await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true, organization_id: orgId }, SYS); - await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true, organization_id: orgId }, SYS); + // A delegatable position + a non-delegatable one, declared where the gate + // and the resolver read a position (ADR-0131 D3/D4): the security catalog, + // through the metadata door. The delegator DIRECTLY holds the delegatable one. + for (const [name, label, delegatable] of [ + ['vacation_approver', 'Vacation Approver', true], + ['locked_duty', 'Locked Duty', false], + ] as const) { + const saved = await stack.apiAs(adminToken, 'PUT', `/meta/position/${name}`, { name, label, delegatable }); + expect(saved.status, await saved.clone().text().catch(() => '')).toBe(200); + } await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver', organization_id: orgId }, SYS); await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty', organization_id: orgId }, SYS); }, 90_000); diff --git a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts index 33d70d40866..52d006c86d3 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -10,7 +10,7 @@ // `objectstack dev`/`serve`/`start` actually take: the app declares the default // profile in METADATA, the CLI computes its name with `appDefaultPermissionSetName` // and passes only that NAME as `fallbackPermissionSet`, and the SecurityPlugin -// resolves the full set (incl. `readScope`) from `sys_permission_set` at request +// resolves the full set (incl. `readScope`) from the security catalog at request // time. The bug this guards: the artifact-serve path used to drop `permissions[]` // from the stack config, so `appDefaultPermissionSetName` saw nothing, the fallback // silently degraded to the built-in owner-only `member_default`, and a grant-less @@ -47,19 +47,19 @@ interface World { stack: VerifyStack; tokens: Record; } // Same BU world as the reference test: bu_parent ⊃ bu_child (sibling bu_other). // alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note. -// The scope profile is NOT passed as a bootstrap permission set — it is seeded -// into `sys_permission_set` (the runtime home of an app-declared `permission`) -// and reached only by NAME via `fallbackPermissionSet`. +// The scope profile is NOT passed as a bootstrap permission set — it is saved +// through the metadata door into the security catalog (ADR-0131 D3, the one +// home of a permission set) and reached only by NAME via `fallbackPermissionSet`. async function bootFallbackWorld(withResolver = true): Promise { const stack = await bootShowcase({ // Mirror the CLI exactly: the app's isDefault profile name, computed off the // declared `permissions[]`, handed to SecurityPlugin as the fallback. No - // `defaultPermissionSets` carry the scope profile — it must resolve from DB. + // `defaultPermissionSets` carry the scope profile — it must resolve from the catalog. security: new SecurityPlugin({ fallbackPermissionSet: appDefaultPermissionSetName([DEFAULT_PROFILE_METADATA]), }), }); - await stack.signIn(); + const adminToken = await stack.signIn(); const tokens = {} as Record; for (const who of WHO) tokens[who] = await stack.signUp(`fb-${who}@verify.test`); @@ -91,16 +91,16 @@ async function bootFallbackWorld(withResolver = true): Promise { }; if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver); - // Seed the app-declared profile into `sys_permission_set` — this is what an - // app `permission` metadata becomes at runtime, and what the named fallback - // resolves through the SecurityPlugin dbLoader. `readScope` rides inside - // object_permissions JSON. - await sys('sys_permission_set', { + // Save the app-declared profile through the metadata door — the security + // catalog the named fallback resolves through (the SecurityPlugin's catalog + // loader, ADR-0131 D3/D4; a `sys_permission_set` row is not read). + // `readScope` rides inside `objects`. + const saved = await stack.apiAs(adminToken, 'PUT', `/meta/permission/${PROFILE_NAME}`, { name: PROFILE_NAME, label: DEFAULT_PROFILE_METADATA.label, - active: true, - object_permissions: JSON.stringify(DEFAULT_PROFILE_METADATA.objects), + objects: DEFAULT_PROFILE_METADATA.objects, }); + expect(saved.status, await saved.clone().text().catch(() => '')).toBe(200); const uid = async (who: Who) => (await ql.findOne('sys_user', { where: { email: `fb-${who}@verify.test` }, context: { isSystem: true } }))?.id; From 5381fd16ac00895c297e7315ecaa47cc86e99bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:25:59 +0000 Subject: [PATCH 38/44] changeset: the one-holder refusal's envelope, as the wire answers it Claude-Session: https://claude.ai/code/session_01VHtcG1CjvZzd7U6F9Yg8oN Co-authored-by: Claude --- .changeset/15204-s2a-security-readers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15204-s2a-security-readers.md b/.changeset/15204-s2a-security-readers.md index 2daa60db6a6..b4cee89bd8a 100644 --- a/.changeset/15204-s2a-security-readers.md +++ b/.changeset/15204-s2a-security-readers.md @@ -35,6 +35,6 @@ Clause-②: yes **One position name per deployment under `single`.** - FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). -- TO: refused `409 UNIQUE_VIOLATION` on `name`, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. +- TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. **`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. From d0cab9f80d8394171d72f006d8c7c11b25e2eb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:08:24 +0000 Subject: [PATCH 39/44] port #22750: render-projection-diff generates the base's git-ignored migration registry Byte-identical to that PR's head 2a4ccb4878 for render-projection-diff.ts and its test; a no-op once main carries it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U --- .../scripts/render-projection-diff.test.ts | 110 ++++++++++++++++++ .../spec/scripts/render-projection-diff.ts | 51 +++++++- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/spec/scripts/render-projection-diff.test.ts b/packages/spec/scripts/render-projection-diff.test.ts index 550740f12c8..e095311e290 100644 --- a/packages/spec/scripts/render-projection-diff.test.ts +++ b/packages/spec/scripts/render-projection-diff.test.ts @@ -16,13 +16,16 @@ import path from 'node:path'; import { ARTIFACT_NAME, + BASE_REGISTRY, SUMMARY_DIFF_CAP, describeDeltas, fenceFor, + generateBaseRegistry, idDeltas, renderProjectionDiff, renderSummary, type Generate, + type RunScript, } from './render-projection-diff'; let tmp: string; @@ -149,6 +152,113 @@ describe('render-projection-diff — the generated diff on a pull request', () = }); }); +describe('render-projection-diff — a base whose migration registry is git-ignored', () => { + /** A base `packages/spec` laid out as its archive would be: the generator and/or the registry, or neither. */ + function baseSpecDir(has: { generator: boolean; registry: boolean }): string { + const dir = path.join(tmp, 'base-tree', 'packages', 'spec'); + fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + if (has.generator) fs.writeFileSync(path.join(dir, 'scripts', BASE_REGISTRY.generator), ''); + if (has.registry) { + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// committed\n'); + } + return dir; + } + + /** A fake runner that records each call and, unless told otherwise, writes the registry the way the generator does. */ + function recorder(result: { status: number; write: boolean; output?: string }) { + const calls: [string, string][] = []; + const run: RunScript = (dir, script) => { + calls.push([dir, script]); + if (result.write) fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// generated\n'); + return { status: result.status, output: result.output ?? '' }; + }; + return { calls, run }; + } + + it('an archive with the generator but no registry runs the BASE generator in the base tree', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true }); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(true); + expect(calls).toEqual([[dir, 'build-migration-registry.ts']]); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// generated\n'); + }); + + it('a base that committed its registry keeps it, and a base without the generator is left as it is', () => { + for (const has of [{ generator: true, registry: true }, { generator: false, registry: true }, { generator: false, registry: false }]) { + fs.rmSync(path.join(tmp, 'base-tree'), { recursive: true, force: true }); + const dir = baseSpecDir(has); + const { calls, run } = recorder({ status: 0, write: true }); + + expect(generateBaseRegistry(dir, run)).toBe(false); + expect(calls).toEqual([]); + if (has.registry) expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// committed\n'); + } + }); + + it('a base registry generator that fails, or exits 0 without writing, is thrown with its output', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + + // First line names the generator and its exit; the generator's output follows it — the + // shape the failure summary splits on. + expect(() => generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run)).toThrow( + /^build-migration-registry\.ts exited 1\b[^\n]*\nentry failed to parse$/, + ); + expect(() => generateBaseRegistry(dir, recorder({ status: 0, write: false }).run)).toThrow( + /^build-migration-registry\.ts exited 0\b[^\n]*src\/migrations\/registry\.ts/, + ); + }); + + it('a base whose registry cannot be generated is RED, named as the base', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + const generate: Generate = (_dir, script, out) => { + fs.writeFileSync(out, script === 'build-spec-changes.ts' ? BASE.json : BASE.guide); + return { status: 0, output: '' }; + }; + const head = path.join(tmp, 'head-tree', 'packages', 'spec'); + fs.mkdirSync(head, { recursive: true }); + const outDir = path.join(tmp, 'out'); + const result = renderProjectionDiff({ + repoRoot: tmp, + baseSha: 'b'.repeat(40), + baseLabel: 'HEAD^1 = bbbbbbbbbb', + outDir, + headSpecDir: head, + generate, + materializeBase: () => { + generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run); + return dir; + }, + }); + + expect(result.code).toBe(1); + expect(result.headline).toContain('base HEAD^1 = bbbbbbbbbb: build-migration-registry.ts exited 1'); + expect(result.summary).toContain('entry failed to parse'); + }); + + it('the default runner executes the base generator by its own path, with the base package as cwd', () => { + const dir = baseSpecDir({ generator: true, registry: false }); + // Resolves its package root from its own path, as the real generator does. + fs.writeFileSync( + path.join(dir, 'scripts', BASE_REGISTRY.generator), + [ + "import { mkdirSync, writeFileSync } from 'node:fs';", + "import { dirname, join } from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');", + "mkdirSync(join(pkgRoot, 'src', 'migrations'), { recursive: true });", + "writeFileSync(join(pkgRoot, 'src', 'migrations', 'registry.ts'), `// cwd ${process.cwd()}\\n`);", + '', + ].join('\n'), + ); + + expect(generateBaseRegistry(dir)).toBe(true); + expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe(`// cwd ${fs.realpathSync(dir)}\n`); + }); +}); + describe('render-projection-diff — the pieces', () => { it('idDeltas names ids gained and lost per record, and skips records that did not move', () => { const deltas = idDeltas(manifest(['a', 'b'], ['c1']), manifest(['b', 'z'], ['c1', 'c2'])); diff --git a/packages/spec/scripts/render-projection-diff.ts b/packages/spec/scripts/render-projection-diff.ts index e299077fa41..07690f29a8e 100644 --- a/packages/spec/scripts/render-projection-diff.ts +++ b/packages/spec/scripts/render-projection-diff.ts @@ -27,6 +27,8 @@ * base ``'s own generators, run in a `git archive` of `` that * borrows this checkout's `node_modules` — so the base side is what the * base's code generated, never this change's generator applied to it. + * A base whose migration registry is git-ignored gets it written first, + * by its own generator (`generateBaseRegistry`). * * ⚠️ A base whose generators predate `--out` writes its committed path instead * (inside the throwaway archive, never this checkout). That is read in its @@ -86,8 +88,11 @@ export interface GeneratorRun { /** Runs `/scripts/