From 358cb991cd7ea516c13c37074d5ccded84659e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:32:09 +0000 Subject: [PATCH 1/8] wip(plugin-security): grant permission-set name backfill module and its boot wiring (ADR-0131 C2 S4b) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/grant-permission-set-name-backfill.ts | 556 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 31 +- 2 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts new file mode 100644 index 00000000000..1448f7cbbf4 --- /dev/null +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts @@ -0,0 +1,556 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one-time backfill of `sys_user_permission_set.permission_set` — the + * grant's permission set BY NAME ([ADR-0131] D4) — on the grants written + * before the column existed. + * + * ## What it does + * + * Every grant written since the column landed names its set: the platform's + * writers write both columns, and the engine hooks in + * `grant-permission-set-name.ts` derive the name from `permission_set_id` for + * every other writer. Grants written before that carry `NULL`. This pass gives + * each of them the name its own id already points at, so every grant obeys the + * column's one rule — system-written, agreeing with the id — before any reader + * is switched onto the name. It changes no reader, no id and no grant's + * meaning, and it deletes nothing (deletions are ADR-0131 C7's). + * + * For each grant with no name: + * + * 1. **The set row its id names, read inside the grant's own wall.** The read + * runs under the grant's organization (`seedCtx`), so it routes through the + * driver's tenant scope — the one governed spelling of the wall, the same + * read the hooks and the authorization resolver's position read make — + * and sees that organization's rows and the organization-less ones. The + * grant rule the resolver applies to every stored grant row is then asked + * of the set row: an organization-less row applies everywhere, a row of an + * organization applies only to a grant of that same organization. So an + * organization-less grant (one that applies in every organization) may + * name only an organization-less set. + * 2. **The name is verified through the security catalog read** (S1's + * `createSecurityCatalogReader`, `@objectstack/core`) before it is written: + * a name the catalog does not resolve is not written. + * 3. **Written as a system update of the name alone**, under the grant's own + * organization. The update hook re-derives the name from the row's stored + * id through its own walled read and refuses a disagreement, so a grant + * whose id moved between this pass's read and its write is refused, not + * mislabelled. + * + * ## Report, never guess + * + * A grant this pass cannot name keeps `NULL` and is reported, by count and + * grant row id. Nothing about another organization is logged: + * + * - **an id that names no set row** (`warn`) — it grants nothing today either; + * - **an id whose set row belongs to another organization** (`warn`) — its + * name is never written: a name carries no organization, so writing another + * organization's name would point the grant at whatever this organization + * calls by that name; + * - **a set row whose name the catalog read does not resolve** (`error`) — + * the grant points at a definition the catalog does not hold, so it would + * fail closed once readers read the name. + * + * ## Once, and remembered + * + * The pass runs until it has reached a verdict once, records that verdict in + * the deployment ledger `sys_migration`, and every later pass reads the record + * and does nothing — the shape of `plugin-auth`'s + * `membership-backfill-ledger.ts`. The row: its own id + * ({@link GRANT_SET_NAME_BACKFILL_MIGRATION_ID}), `verified_at: null` (no + * consumer gates on it, so it certifies nothing) and `blocking: 0` (it holds + * no consumer closed); `details` carries what the pass did. + * + * Recorded — nothing a later pass could change: every unnamed grant was named, + * or names no set row, or names another organization's set row. + * + * Not recorded — a later pass may still decide differently, so it gets its + * turn on the next boot: + * + * - a set row whose name the catalog read did not resolve: the catalog is + * filled by code, environment metadata and installed packages, and a + * definition missing now may be registered later; + * - a read that did not happen (the grant scan, a set row, the catalog), or a + * name write that did not land. + * + * Without a readable ledger the pass still runs — it only fills a `NULL` with + * the name the row's own id already points at, so running it twice writes + * nothing the first run did not — but its verdict cannot be remembered, and + * every boot scans again. + * + * ## When it runs + * + * At `kernel:bootstrapped`, from `SecurityPlugin.start`: after every + * `kernel:ready` handler has settled — the platform bootstrap that seeds the + * catalog rows, and the environment metadata hydrated before it — so the + * catalog read sees every code and environment definition this boot will + * register. + * + * ADR anchors: ADR-0131 D4 (references by name), D10 (the id column is dropped + * after a verified rewrite — not here), C7 (deletions — not here). + */ + +import { classifyFilterToken } from '@objectstack/spec/data'; +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import type { SecurityCatalogReader } from '@objectstack/core'; +import { + GRANT_OBJECT, + GRANT_SET_ID_FIELD, + GRANT_SET_NAME_FIELD, + PERMISSION_SET_CATALOG_OBJECT, +} from './grant-permission-set-name.js'; +import { rowOrganizationId, seedCtx } from './per-organization-catalog.js'; + +/** Ledger row id of the one-time grant name backfill. */ +export const GRANT_SET_NAME_BACKFILL_MIGRATION_ID = 'adr-0131-grant-permission-set-name-backfill'; + +/** Rows per page of the unnamed-grant scan. */ +const SCAN_PAGE_SIZE = 500; + +/** Grant row ids printed per category; the count is always complete. */ +const LOGGED_ID_LIMIT = 50; + +const SYSTEM_CTX = { isSystem: true } as const; + +/** The engine surface the backfill uses — ObjectQL satisfies it as it stands. */ +export interface GrantNameBackfillEngine { + getObject(name: string): unknown; + find(object: string, options: Record): Promise; + findOne(object: string, options: Record): Promise | null>; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options?: Record): Promise; +} + +/** The kernel logger, as this module uses it. */ +export interface GrantNameBackfillLogger { + info?: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + /** The kernel logger's shape: message, cause, meta. */ + error?: (message: string, cause?: unknown, meta?: Record) => void; +} + +/** Why a pass stopped before it reached a verdict. */ +export type GrantNameBackfillStop = + /** No grant object, or no permission-set catalog object, in this composition. */ + | 'objects-absent' + /** The unnamed-grant scan could not be read in full. */ + | 'scan-unreadable' + /** A set row could not be read. */ + | 'set-row-unreadable' + /** The security catalog read did not happen. */ + | 'catalog-unreadable'; + +/** What one pass did. Every list holds grant row ids. */ +export interface GrantNameBackfillResult { + /** Unnamed grants the scan found. */ + unnamed: number; + /** Grants this pass named. */ + named: string[]; + /** Grants whose id names no set row. */ + dangling: string[]; + /** Grants whose id names another organization's set row. */ + crossOrganization: string[]; + /** Grants whose set row's name the catalog read did not resolve. */ + unresolved: string[]; + /** Grants whose name write did not land. */ + failed: string[]; + /** Set when the pass stopped before it judged every unnamed grant. */ + stopped?: GrantNameBackfillStop; +} + +export interface GrantNameBackfillDeps { + /** S1's security catalog read; every name is verified through it before it is written. */ + catalog: SecurityCatalogReader; + logger?: GrantNameBackfillLogger; +} + +/** What the set row a grant's id names comes to, inside the grant's wall. */ +type SetVerdict = + | { kind: 'name'; name: string } + | { kind: 'dangling' } + | { kind: 'cross-organization' } + | { kind: 'unresolved'; name: string }; + +/** Thrown inside the pass to stop it; never escapes the module. */ +class BackfillStop extends Error { + constructor(readonly stop: GrantNameBackfillStop, readonly reason: unknown) { + super(stop); + } +} + +/** The store a stop could not read, as the report names it. */ +const STOPPED_READING: Record = { + 'objects-absent': 'this composition', + 'scan-unreadable': GRANT_OBJECT, + 'set-row-unreadable': PERMISSION_SET_CATALOG_OBJECT, + 'catalog-unreadable': 'the security catalog', +}; + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +/** Whether a grant row's name column carries a name — `null` and a blank do not. */ +function carriesName(value: unknown): boolean { + return typeof value === 'string' && value.trim() !== ''; +} + +/** + * The id a `permission_set_id` value reads by, or `undefined` for a value that + * names no row: only a non-blank string or a finite number is an id, and a + * placeholder-shaped string (`{…}`) is never one — in `where` it would be + * resolved as a filter token rather than compared. + */ +function idKey(value: unknown): string | undefined { + let key: string | undefined; + if (typeof value === 'string') key = value; + else if (typeof value === 'number' && Number.isFinite(value)) key = String(value); + if (key === undefined || key.trim() === '') return undefined; + if (classifyFilterToken(key) !== null) return undefined; + return key; +} + +/** + * The grant rule the authorization resolver asks of every stored grant row, + * asked here of the set row a grant names: an organization-less row applies + * everywhere; a row of an organization applies only within that organization. + */ +function setRowAppliesToGrant(setOrganizationId: string | null, grantOrganizationId: string | null): boolean { + return setOrganizationId === null || setOrganizationId === grantOrganizationId; +} + +/** Read every unnamed grant in full, ordered by id, before anything is written. */ +async function scanUnnamedGrants(engine: GrantNameBackfillEngine): Promise[]> { + const byId = new Map>(); + // NULL is the pre-column shape; a blank is what a system write may have left + // beside an id that resolved nothing. Neither is a name. + for (const value of [null, '']) { + for (let offset = 0; ; offset += SCAN_PAGE_SIZE) { + let page: unknown; + try { + page = await engine.find(GRANT_OBJECT, { + where: { [GRANT_SET_NAME_FIELD]: value }, + fields: ['id', GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD, 'organization_id'], + orderBy: [{ field: 'id', order: 'asc' }], + limit: SCAN_PAGE_SIZE, + offset, + context: SYSTEM_CTX, + }); + } catch (e) { + throw new BackfillStop('scan-unreadable', e); + } + const rows = Array.isArray(page) ? page : []; + for (const row of rows) { + const id = (row as Record | null)?.id; + if (typeof id === 'string' && id !== '' && !carriesName((row as Record)[GRANT_SET_NAME_FIELD])) { + byId.set(id, row as Record); + } + } + if (rows.length < SCAN_PAGE_SIZE) break; + } + } + return [...byId.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))); +} + +/** What the set row `setId` comes to for a grant of `grantOrganizationId`. */ +async function judgeSetRow( + engine: GrantNameBackfillEngine, + catalog: SecurityCatalogReader, + setId: string, + grantOrganizationId: string | null, + resolvedNames: Map, +): Promise { + let row: Record | null; + try { + row = await engine.findOne(PERMISSION_SET_CATALOG_OBJECT, { + where: { id: setId }, + fields: ['id', 'name', 'organization_id'], + context: seedCtx(grantOrganizationId ?? undefined), + }); + } catch (e) { + throw new BackfillStop('set-row-unreadable', e); + } + if (row && !setRowAppliesToGrant(rowOrganizationId(row), grantOrganizationId)) { + return { kind: 'cross-organization' }; + } + if (!row) { + // Nothing inside the wall. Whether the id names a row OUTSIDE it decides + // which report the grant lands in; only the row's existence is read. + let outside: Record | null; + try { + outside = await engine.findOne(PERMISSION_SET_CATALOG_OBJECT, { + where: { id: setId }, + fields: ['id'], + context: SYSTEM_CTX, + }); + } catch (e) { + throw new BackfillStop('set-row-unreadable', e); + } + return outside ? { kind: 'cross-organization' } : { kind: 'dangling' }; + } + + const name = row.name; + if (typeof name !== 'string' || name.trim() === '') return { kind: 'unresolved', name: String(name ?? '') }; + let resolves = resolvedNames.get(name); + if (resolves === undefined) { + try { + const entry = await catalog.resolve('permission', name); + resolves = entry !== undefined && entry.name === name; + } catch (e) { + throw new BackfillStop('catalog-unreadable', e); + } + resolvedNames.set(name, resolves); + } + return resolves ? { kind: 'name', name } : { kind: 'unresolved', name }; +} + +/** At most {@link LOGGED_ID_LIMIT} ids, and how many more there are. */ +function listed(ids: readonly string[]): { ids: string[]; more?: number } { + return ids.length > LOGGED_ID_LIMIT + ? { ids: ids.slice(0, LOGGED_ID_LIMIT), more: ids.length - LOGGED_ID_LIMIT } + : { ids: [...ids] }; +} + +function logError(logger: GrantNameBackfillLogger | undefined, message: string, meta: Record): void { + if (logger?.error) logger.error(message, undefined, meta); + else logger?.warn(message, meta); +} + +/** Report what one pass could not name — once per category, never once per row. */ +function reportPass(result: GrantNameBackfillResult, unresolvedNames: string[], logger?: GrantNameBackfillLogger): void { + if (result.named.length > 0) { + logger?.info?.( + `[security] ${result.named.length} of ${result.unnamed} unnamed ${GRANT_OBJECT} grant(s) now name their ` + + `permission set (ADR-0131 D4): each name was read from the set row its ${GRANT_SET_ID_FIELD} points at, ` + + `inside the grant's own organization, and resolved in the security catalog before it was written`, + { named: result.named.length, unnamed: result.unnamed }, + ); + } + if (result.dangling.length > 0) { + logger?.warn( + `[security] ${result.dangling.length} ${GRANT_OBJECT} grant(s) keep no ${GRANT_SET_NAME_FIELD}: their ` + + `${GRANT_SET_ID_FIELD} names no ${PERMISSION_SET_CATALOG_OBJECT} row, so they grant nothing today and ` + + `there is no name to give them. Nothing was deleted; review these grants and remove or re-point them.`, + { count: result.dangling.length, grants: listed(result.dangling) }, + ); + } + if (result.crossOrganization.length > 0) { + logger?.warn( + `[security] ${result.crossOrganization.length} ${GRANT_OBJECT} grant(s) keep no ${GRANT_SET_NAME_FIELD}: ` + + `their ${GRANT_SET_ID_FIELD} names a ${PERMISSION_SET_CATALOG_OBJECT} row outside the grant's own ` + + `organization (an organization-less grant may name only an organization-less set), and a name is never ` + + `carried across organizations. Nothing was changed; re-point each grant at a set of its own organization.`, + { count: result.crossOrganization.length, grants: listed(result.crossOrganization) }, + ); + } + if (result.unresolved.length > 0) { + logError( + logger, + `[security] ${result.unresolved.length} ${GRANT_OBJECT} grant(s) were NOT given a ${GRANT_SET_NAME_FIELD}: ` + + `the ${PERMISSION_SET_CATALOG_OBJECT} row each one points at carries a name the security catalog does not ` + + `resolve at this boot, so the name cannot be shown to name a definition. These grants still resolve by ` + + `id today, and would fail closed once readers read the name. Fix: register the permission set ` + + `definition (its package or environment metadata), or re-point the grants; the backfill runs again on ` + + `the next boot.`, + { count: result.unresolved.length, names: unresolvedNames, grants: listed(result.unresolved) }, + ); + } + if (result.failed.length > 0) { + logError( + logger, + `[security] ${result.failed.length} ${GRANT_OBJECT} name write(s) did NOT land, so those grants still ` + + `carry no ${GRANT_SET_NAME_FIELD} while every other line of this backfill reads clean. The backfill runs ` + + `again on the next boot; if it fails again, check that ${GRANT_OBJECT} is writable by the system context.`, + { count: result.failed.length, grants: listed(result.failed) }, + ); + } +} + +/** + * One pass: name every unnamed grant whose name can be verified, report the + * rest. A read that did not happen stops the pass; the stop is returned in + * `stopped` and reported, never thrown. + */ +export async function backfillGrantPermissionSetNames( + engine: GrantNameBackfillEngine, + deps: GrantNameBackfillDeps, +): Promise { + const result: GrantNameBackfillResult = { + unnamed: 0, named: [], dangling: [], crossOrganization: [], unresolved: [], failed: [], + }; + const { catalog, logger } = deps; + if (!engine?.getObject?.(GRANT_OBJECT) || !engine.getObject(PERMISSION_SET_CATALOG_OBJECT)) { + result.stopped = 'objects-absent'; + return result; + } + + const unresolvedNames = new Set(); + try { + const grants = await scanUnnamedGrants(engine); + result.unnamed = grants.length; + const verdicts = new Map(); + const resolvedNames = new Map(); + + for (const grant of grants) { + const grantId = String(grant.id); + const setId = idKey(grant[GRANT_SET_ID_FIELD]); + if (setId === undefined) { + result.dangling.push(grantId); + continue; + } + const grantOrganizationId = rowOrganizationId(grant); + const memoKey = JSON.stringify([grantOrganizationId, setId]); + let verdict = verdicts.get(memoKey); + if (!verdict) { + verdict = await judgeSetRow(engine, catalog, setId, grantOrganizationId, resolvedNames); + verdicts.set(memoKey, verdict); + } + + if (verdict.kind === 'dangling') result.dangling.push(grantId); + else if (verdict.kind === 'cross-organization') result.crossOrganization.push(grantId); + else if (verdict.kind === 'unresolved') { + result.unresolved.push(grantId); + unresolvedNames.add(verdict.name); + } else { + try { + await engine.update( + GRANT_OBJECT, + { id: grantId, [GRANT_SET_NAME_FIELD]: verdict.name }, + { context: seedCtx(grantOrganizationId ?? undefined) }, + ); + result.named.push(grantId); + } catch { + result.failed.push(grantId); + } + } + } + } catch (e) { + if (!(e instanceof BackfillStop)) throw e; + result.stopped = e.stop; + logger?.warn( + `[security] the ${GRANT_OBJECT} name backfill stopped before it judged every unnamed grant: ` + + `${STOPPED_READING[e.stop]} could not be read, and an unread answer is never taken for "no such set". ` + + `No name was written past that point; the backfill runs again on the next boot.`, + { stop: e.stop, error: errorText(e.reason), named: result.named.length }, + ); + } + reportPass(result, [...unresolvedNames].sort(), logger); + return result; +} + +/** A pass's verdict is final when nothing a later pass could decide differently remains. */ +export function isRecordableGrantNameVerdict(result: GrantNameBackfillResult): boolean { + return result.stopped === undefined && result.unresolved.length === 0 && result.failed.length === 0; +} + +/** What the deployment ledger says about this backfill. */ +export type GrantNameLedgerReading = 'recorded' | 'absent' | 'unavailable' | 'unreadable'; + +/** Read the ledger row by its id. Never throws. */ +export async function readGrantNameBackfillLedger(engine: GrantNameBackfillEngine): Promise { + try { + if (!engine?.getObject?.(DATA_MIGRATION_FLAG_OBJECT)) return 'unavailable'; + const row = await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: GRANT_SET_NAME_BACKFILL_MIGRATION_ID }, + context: SYSTEM_CTX, + }); + return row?.id === GRANT_SET_NAME_BACKFILL_MIGRATION_ID ? 'recorded' : 'absent'; + } catch { + return 'unreadable'; + } +} + +/** The ledger row for a decided pass — pure. */ +export function buildGrantNameBackfillRecord(result: GrantNameBackfillResult, now: string): DataMigrationFlag { + return { + id: GRANT_SET_NAME_BACKFILL_MIGRATION_ID, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + details: JSON.stringify({ + unnamed: result.unnamed, + named: result.named.length, + dangling: result.dangling.length, + crossOrganization: result.crossOrganization.length, + }), + }; +} + +/** + * Insert the ledger row. THROWS on failure — the caller decides what a lost + * record means. + */ +async function persistGrantNameBackfillRecord(engine: GrantNameBackfillEngine, flag: DataMigrationFlag): Promise { + const now = flag.last_run_at; + await engine.insert(DATA_MIGRATION_FLAG_OBJECT, { ...flag, created_at: now, updated_at: now }, { context: SYSTEM_CTX }); +} + +export type GrantNameBackfillStatus = + /** The pass decided, and its record landed. */ + | 'ran' + /** The pass decided, but its record did not land (or there is no ledger to land it in). */ + | 'ran-unrecorded' + /** The pass left something a later pass may decide differently — retried on the next boot. */ + | 'undecided' + /** The ledger already records the verdict — nothing was scanned or written. */ + | 'already-run'; + +export interface OneTimeGrantNameBackfillResult { + status: GrantNameBackfillStatus; + ledger: GrantNameLedgerReading; + /** The pass's own summary, present whenever it ran. */ + backfill?: GrantNameBackfillResult; +} + +/** + * Run the grant name backfill unless the deployment ledger already records + * its verdict, and record the verdict when this pass reaches one. A read the + * pass could not make is reported and returned, never thrown; the boot hook + * that calls this still guards against anything unforeseen. + */ +export async function runOneTimeGrantPermissionSetNameBackfill( + engine: GrantNameBackfillEngine, + deps: GrantNameBackfillDeps, +): Promise { + const { logger } = deps; + const ledger = await readGrantNameBackfillLedger(engine); + if (ledger === 'recorded') return { status: 'already-run', ledger }; + + const backfill = await backfillGrantPermissionSetNames(engine, deps); + if (!isRecordableGrantNameVerdict(backfill)) return { status: 'undecided', ledger, backfill }; + + if (ledger !== 'absent') { + logger?.warn( + `[security] the ${GRANT_OBJECT} name backfill reached its verdict but cannot record it: the deployment ` + + `ledger ${DATA_MIGRATION_FLAG_OBJECT} is ${ledger === 'unavailable' ? 'not available on this kernel' : 'unreadable'}, ` + + `so every boot scans the grants again. Nothing is renamed twice. Compose PlatformObjectsPlugin (it ` + + `provisions the ledger) to let the verdict be remembered.`, + { ledger }, + ); + return { status: 'ran-unrecorded', ledger, backfill }; + } + + const flag = buildGrantNameBackfillRecord(backfill, new Date().toISOString()); + try { + await persistGrantNameBackfillRecord(engine, flag); + } catch (e) { + // A concurrent boot that landed the same id first has recorded it. + if ((await readGrantNameBackfillLedger(engine)) === 'recorded') return { status: 'ran', ledger, backfill }; + // At `error`: the pass's writes stand and every line reads clean, while the + // record that makes it one-time is absent. + logError( + logger, + `[security] the ${GRANT_OBJECT} name backfill ran, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed ` + + `(${errorText(e)}). The next boot scans every grant again and re-reports what it cannot name. Fix: make ` + + `${DATA_MIGRATION_FLAG_OBJECT} writable on this deployment (it is provisioned by PlatformObjectsPlugin) ` + + `and verify with SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = '${GRANT_SET_NAME_BACKFILL_MIGRATION_ID}'.`, + { error: errorText(e) }, + ); + return { status: 'ran-unrecorded', ledger, backfill }; + } + logger?.info?.( + `[security] the ${GRANT_OBJECT} name backfill is recorded in ${DATA_MIGRATION_FLAG_OBJECT} ` + + `(id '${GRANT_SET_NAME_BACKFILL_MIGRATION_ID}') — later boots will not run it again`, + { id: flag.id, details: flag.details }, + ); + return { status: 'ran', ledger, backfill }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3fb0c694a6b..c9b7eed3478 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -39,6 +39,7 @@ import { registerGrantPermissionSetNameHooks, unregisterGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; +import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; import { explainAccess, buildContextForUser, @@ -96,7 +97,7 @@ import { PLATFORM_OWNER_WALL_BYPASS_EVENT, isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader } from '@objectstack/core'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy, @@ -4812,6 +4813,34 @@ export class SecurityPlugin implements Plugin { void runBootstrap(); } + // [ADR-0131 D4] Name the permission set on every grant written before + // `sys_user_permission_set.permission_set` existed — once per deployment, + // recorded in `sys_migration`. At `kernel:bootstrapped`, not `kernel:ready`: + // every `kernel:ready` handler has settled by then (the bootstrap above that + // seeds the catalog rows among them), so the security catalog read each + // name is verified through sees every definition this boot registers. A + // name it still does not resolve leaves the verdict unrecorded, so the next + // boot tries again. See `grant-permission-set-name-backfill.ts`. + const runGrantNameBackfill = async (): Promise => { + try { + await runOneTimeGrantPermissionSetNameBackfill(ql as any, { + catalog: createSecurityCatalogReader({ registry: (ql as any).registry, metadata: this.metadata }), + logger: ctx.logger, + }); + } catch (e) { + ctx.logger.warn( + '[security] the sys_user_permission_set name backfill did not run — grants written before the ' + + 'permission_set column existed keep no name until a later boot runs it', + { error: (e as Error)?.message }, + ); + } + }; + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:bootstrapped', runGrantNameBackfill); + } else { + void runGrantNameBackfill(); + } + // ── Project the permission sets of a package that arrives AFTER the boot ── // // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The From 507c035ec4d4d569ea95a142c9b9657000a8b7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:41:48 +0000 Subject: [PATCH 2/8] test(plugin-security): pins for the grant permission-set name backfill (ADR-0131 C2 S4b) Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...grant-permission-set-name-backfill.test.ts | 680 ++++++++++++++++++ .../src/grant-permission-set-name-backfill.ts | 3 +- 2 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts 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 new file mode 100644 index 00000000000..4a8d5109363 --- /dev/null +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -0,0 +1,680 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] The one-time backfill of `sys_user_permission_set.permission_set` + * on grants written before the column existed. + * + * Measured on a REAL `ObjectQL` engine over a real SQL driver with the REAL + * `SecurityPlugin` started on it, so the backfill's writes pass the same + * engine, driver wall and name hooks a deployment's do. A grant "written + * before the column existed" is made by writing it with the name hooks + * unbound and clearing the name, which is the stored shape such a grant has. + * + * The catalog the names are verified through is S1's read + * (`createSecurityCatalogReader`) over the engine registry, which holds the + * permission-set definitions here as a package manifest's `permissions` put + * them there in a deployment. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + assembleExecutionContext, + createSecurityCatalogReader, + resetPlatformAdminEmailMemo, + resolveUserAuthzGrants, + type SecurityCatalogReader, +} from '@objectstack/core'; +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'; +import { SysMigration } from '@objectstack/platform-objects/system'; + +import { SecurityPlugin } from './security-plugin.js'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +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'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { + registerGrantPermissionSetNameHooks, + unregisterGrantPermissionSetNameHooks, +} from './grant-permission-set-name.js'; +import { + GRANT_SET_NAME_BACKFILL_MIGRATION_ID, + runOneTimeGrantPermissionSetNameBackfill, +} from './grant-permission-set-name-backfill.js'; + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_eq'; +const OTHER_ORG = 'org_other'; +const CATALOG_PACKAGE = 'com.objectstack.qa.grant-name-backfill'; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +/** A tenant's own set, and the other organization's set of another name. */ +const OWN_SET = { name: 'eq_reviewer', label: 'Reviewer', objects: {} } as unknown as PermissionSet; +const OTHER_SET = { name: 'other_auditor', label: 'Auditor', objects: {} } as unknown as PermissionSet; + +type Posture = 'single' | 'isolated'; +type Engine = ObjectQL; +const engines: Engine[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +const newLogger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }); + +interface World { + engine: Engine; + catalog: SecurityCatalogReader; + /** Kernel lifecycle hooks the plugin registered, by event — collected, never fired by the fixture. */ + hooks: Map Promise>>; + pluginLogger: ReturnType; +} + +/** + * A booted engine with the RBAC objects, the deployment ledger and the started + * `SecurityPlugin`, in `posture`; the platform catalog rows seeded by + * `bootstrapPlatformAdmin` (and, in `single`, its promotion), and both + * organizations present. + */ +async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise { + if (posture === 'isolated') { + process.env[POSTURE_ENV] = 'isolated'; + process.env[OWNER_ENV] = 'admin@eq.example'; + } + resetPlatformAdminEmailMemo(); + + 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.grant-name-backfill', + name: 'Grant name backfill', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + ...(opts.ledger === false ? [] : [SysMigration]), + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets, QA_ADMIN], + }, + ...(posture === 'isolated' + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } + : {}), + }; + const hooks = new Map Promise>>(); + const pluginLogger = newLogger(); + const ctx: any = { + logger: pluginLogger, + hook: (event: string, handler: () => Promise) => { + if (!hooks.has(event)) hooks.set(event, []); + hooks.get(event)!.push(handler); + }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + for (const [id, slug] of [[ORG, 'eq'], [OTHER_ORG, 'other']]) { + await engine.insert('sys_organization', { id, name: id, slug }, { context: SYS } as any); + } + // The catalog definitions, where a package manifest's `permissions` put them + // — registered once the organizations exist, so the plugin's + // organization-creation catalog seeding (not this suite's subject) seeds + // exactly what it seeds in the S4a equivalence world. + // Copies: the registry stamps its item in place, and these are shared module objects. + for (const ps of [...defaultPermissionSets, QA_ADMIN, OWN_SET, OTHER_SET]) { + engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); + } + const seeded = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + expect(seeded.adminPromoted).toBe(false); // no user yet — the golden world promotes after its users exist + + const catalog = createSecurityCatalogReader({ registry: engine.registry, metadata: services.metadata as any }); + return { engine, catalog, hooks, pluginLogger }; +} + +const orgCtx = (organizationId: string) => ({ isSystem: true, tenantId: organizationId }); + +async function insertSet(engine: Engine, row: Record, organizationId?: string): Promise { + await engine.insert('sys_permission_set', row, { context: organizationId ? orgCtx(organizationId) : SYS } as any); +} + +async function insertUser(engine: Engine, id: string, email: string, createdAt: string): Promise { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); +} + +/** Write grants the way they were stored before the name column existed: id only, name NULL. */ +async function insertUnnamedGrants(engine: Engine, rows: Array>): Promise { + unregisterGrantPermissionSetNameHooks(engine); + try { + for (const row of rows) { + await engine.insert('sys_user_permission_set', row, { context: SYS } as any); + } + } finally { + registerGrantPermissionSetNameHooks(engine); + } +} + +/** Clear every grant's name — what a grant written before the column carries. */ +async function clearAllNames(engine: Engine): Promise { + unregisterGrantPermissionSetNameHooks(engine); + try { + const rows = (await engine.find('sys_user_permission_set', { fields: ['id'], context: SYS } as any)) as any[]; + for (const row of rows) { + await engine.update('sys_user_permission_set', { id: row.id, permission_set: null }, { context: SYS } as any); + } + } finally { + registerGrantPermissionSetNameHooks(engine); + } +} + +async function grants(engine: Engine): Promise>> { + const rows = (await engine.find('sys_user_permission_set', { + fields: ['id', 'user_id', 'permission_set_id', 'permission_set', 'organization_id'], + orderBy: [{ field: 'id', order: 'asc' }], + context: SYS, + } as any)) as any[]; + return rows; +} + +async function grant(engine: Engine, id: string): Promise | null> { + return (await engine.findOne('sys_user_permission_set', { where: { id }, context: SYS } as any)) as any; +} + +async function ledgerRows(engine: Engine): Promise>> { + return (await engine.find(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: GRANT_SET_NAME_BACKFILL_MIGRATION_ID }, + context: SYS, + } as any)) as any[]; +} + +/** Every string a logger mock was handed — message and meta — for disclosure checks. */ +const loggedText = (logger: ReturnType): string => + JSON.stringify([...logger.info.mock.calls, ...logger.warn.mock.calls, ...logger.error.mock.calls]); + +// --------------------------------------------------------------------------- +// The four principals (the grant-equivalence world) +// --------------------------------------------------------------------------- + +const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); + } + return value; +}; + +/** + * The platform administrator, organization administrator and member written by + * the REAL writers, as the S4a equivalence suite writes them; the organization's + * own catalog copies in the walled posture. + */ +async function seedPrincipals(world: World, posture: Posture): Promise { + const { engine } = world; + await insertUser(engine, 'usr_admin', 'admin@eq.example', '2025-01-01T00:00:00.000Z'); + await insertUser(engine, 'usr_orgadmin', 'orgadmin@eq.example', '2025-02-01T00:00:00.000Z'); + await insertUser(engine, 'usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + const promoted = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + expect(promoted.adminPromoted).toBe(posture === 'single'); + + if (posture === 'isolated') { + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { + const [bucket] = (await engine.find('sys_permission_set', { where: { name }, context: SYS } as any)) as any[]; + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket; + await insertSet(engine, { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, ORG); + } + } + + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + ], { context: orgCtx(ORG) } as any); + const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); + expect(reconciled.action).toBe('granted'); + + const [viewer] = (await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, + context: SYS, + } as any)) as any[]; + await engine.insert( + 'sys_user_permission_set', + { user_id: 'usr_member', permission_set_id: viewer.id }, + { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, + ); +} + +async function grantsByPrincipal(engine: Engine): Promise> { + const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); + const admin = await resolve('usr_admin'); + const orgAdmin = await resolve('usr_orgadmin'); + const member = await resolve('usr_member'); + const agentCtx = assembleExecutionContext({ + authz: { ...orgAdmin, userId: 'usr_orgadmin', tenantId: ORG } as any, + oauth: { + userId: 'usr_orgadmin', + scopes: ['data:read', 'actions:execute'], + clientId: 'agent_client_eq', + scopePermissions: ['mcp_agent_data_read'], + delegatesActions: true, + } as any, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + const agent = { + principalKind: (agentCtx as any)?.principalKind, + positions: (agentCtx as any)?.positions, + permissions: (agentCtx as any)?.permissions, + systemPermissions: (agentCtx as any)?.systemPermissions, + onBehalfOf: (agentCtx as any)?.onBehalfOf, + }; + return sortDeep({ platformAdmin: admin, organizationAdmin: orgAdmin, member, agent }) as Record; +} + +// --------------------------------------------------------------------------- +// Pins +// --------------------------------------------------------------------------- + +describe('[ADR-0131 D4] grant name backfill — every name agrees with the id and resolves through the catalog read', () => { + for (const posture of ['single', 'isolated'] as const) { + it(`${posture}: each unnamed grant gets the name of the set row its id points at, verified through the catalog read`, async () => { + const world = await boot(posture); + const { engine, catalog } = world; + await seedPrincipals(world, posture); + // An organization's grant pointing at the organization-less platform row, + // the shape grants written before the per-organization catalog carry. + const [bucketViewer] = (await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', organization_id: null }, context: SYS, + } as any)) as any[]; + await insertUnnamedGrants(engine, [ + { id: 'g_bucket', user_id: 'usr_orgadmin', permission_set_id: bucketViewer.id, organization_id: ORG }, + ]); + await clearAllNames(engine); + const before = await grants(engine); + expect(before.length).toBe(posture === 'single' ? 4 : 3); + expect(before.every((g) => g.permission_set === null)).toBe(true); + + const logger = newLogger(); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(engine as any, { catalog, logger }); + expect(outcome.status).toBe('ran'); + expect(outcome.backfill?.named.length).toBe(before.length); + + for (const g of await grants(engine)) { + const [set] = (await engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS } as any)) as any[]; + expect(g.permission_set, g.id).toBe(set.name); + const entry = await catalog.resolve('permission', g.permission_set); + expect(entry?.name, g.id).toBe(g.permission_set); + } + expect(logger.error).not.toHaveBeenCalled(); + }); + } +}); + +describe('[ADR-0131 D4] grant name backfill — no principal’s grants change', () => { + for (const posture of ['single', 'isolated'] as const) { + it(`${posture}: platform administrator, organization administrator, member and agent resolve to the golden before and after`, async () => { + const world = await boot(posture); + await seedPrincipals(world, posture); + await clearAllNames(world.engine); + + expect(await grantsByPrincipal(world.engine)).toEqual(GOLDEN[posture]); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { + catalog: world.catalog, logger: newLogger(), + }); + expect(outcome.status).toBe('ran'); + expect((await grants(world.engine)).every((g) => typeof g.permission_set === 'string')).toBe(true); + expect(await grantsByPrincipal(world.engine)).toEqual(GOLDEN[posture]); + }); + } +}); + +describe('[ADR-0131 D4] grant name backfill — report, never guess', () => { + it('an id that names no set row stays unnamed and is reported by count and grant id; the verdict is recorded', async () => { + const { engine, catalog } = await boot('single'); + await insertUser(engine, 'usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + await insertUnnamedGrants(engine, [{ id: 'g_gone', user_id: 'usr_member', permission_set_id: 'ps_gone' }]); + + const logger = newLogger(); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(engine as any, { catalog, logger }); + + expect((await grant(engine, 'g_gone'))?.permission_set).toBeNull(); + expect(outcome.backfill?.dangling).toEqual(['g_gone']); + const [message, meta] = logger.warn.mock.calls.find(([m]) => String(m).includes('names no sys_permission_set row'))!; + expect(String(message)).toMatch(/^\[security\] 1 sys_user_permission_set grant\(s\)/); + expect(meta).toMatchObject({ count: 1, grants: { ids: ['g_gone'] } }); + expect(outcome.status).toBe('ran'); + expect(await ledgerRows(engine)).toHaveLength(1); + }); + + it('a catalog read that answers nothing: no name is written, the refusal is loud, and the verdict is not recorded', async () => { + const world = await boot('single'); + await seedPrincipals(world, 'single'); + await clearAllNames(world.engine); + const silent: SecurityCatalogReader = { resolve: async () => undefined, list: async () => [] }; + + const logger = newLogger(); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { catalog: silent, logger }); + + const after = await grants(world.engine); + expect(after.length).toBe(3); + expect(after.every((g) => g.permission_set === null)).toBe(true); + expect(outcome.backfill?.named).toEqual([]); + expect([...(outcome.backfill?.unresolved ?? [])].sort()).toEqual(after.map((g) => g.id).sort()); + expect(logger.error).toHaveBeenCalledTimes(1); + const [message, , meta] = logger.error.mock.calls[0]; + expect(String(message)).toMatch(/^\[security\] 3 sys_user_permission_set grant\(s\) were NOT given a permission_set/); + expect(meta).toMatchObject({ count: 3 }); + expect(outcome.status).toBe('undecided'); + expect(await ledgerRows(world.engine)).toHaveLength(0); + }); + + it('a name the catalog does not resolve YET leaves the verdict unrecorded; once it is registered, the next pass names it', async () => { + const { engine, catalog } = await boot('single'); + await insertUser(engine, 'usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + await insertSet(engine, { id: 'ps_late', name: 'late_reviewer', label: 'Late' }); + await insertUnnamedGrants(engine, [{ id: 'g_late', user_id: 'usr_member', permission_set_id: 'ps_late' }]); + + const first = await runOneTimeGrantPermissionSetNameBackfill(engine as any, { catalog, logger: newLogger() }); + expect(first.status).toBe('undecided'); + expect(first.backfill?.unresolved).toEqual(['g_late']); + expect((await grant(engine, 'g_late'))?.permission_set).toBeNull(); + expect(await ledgerRows(engine)).toHaveLength(0); + + // A package registering the definition after that pass — a later boot's catalog. + engine.registry.registerItem('permission', { name: 'late_reviewer', label: 'Late', objects: {} } as any, 'name' as any, CATALOG_PACKAGE); + const second = await runOneTimeGrantPermissionSetNameBackfill(engine as any, { catalog, logger: newLogger() }); + expect(second.status).toBe('ran'); + expect((await grant(engine, 'g_late'))?.permission_set).toBe('late_reviewer'); + expect(await ledgerRows(engine)).toHaveLength(1); + }); + + it('a name write that does not land is reported at error and leaves the verdict unrecorded', async () => { + const world = await boot('single'); + await seedPrincipals(world, 'single'); + await clearAllNames(world.engine); + const realUpdate = world.engine.update.bind(world.engine); + vi.spyOn(world.engine, 'update').mockImplementation(async (object: string, data: any, options?: any) => { + if (object === 'sys_user_permission_set') throw new Error('disk full'); + return realUpdate(object, data, options); + }); + + const logger = newLogger(); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { catalog: world.catalog, logger }); + + expect(outcome.status).toBe('undecided'); + expect(outcome.backfill?.failed.length).toBe(3); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(String(logger.error.mock.calls[0][0])).toMatch(/name write\(s\) did NOT land/); + expect(await ledgerRows(world.engine)).toHaveLength(0); + }); +}); + +describe('[ADR-0131 D4] grant name backfill — a name never crosses organizations', () => { + for (const posture of ['single', 'isolated'] as const) { + it(`${posture}: a grant whose id names another organization's set row keeps no name, and nothing of that organization is logged`, async () => { + const { engine, catalog } = await boot(posture); + await insertUser(engine, 'usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + await insertSet(engine, { id: 'ps_other', name: OTHER_SET.name, label: 'Auditor' }, OTHER_ORG); + await insertSet(engine, { id: 'ps_own', name: OWN_SET.name, label: 'Reviewer' }, ORG); + const [bucketViewer] = (await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', organization_id: null }, context: SYS, + } as any)) as any[]; + await insertUnnamedGrants(engine, [ + // The organization's grant on the other organization's set. + { id: 'g_cross', user_id: 'usr_member', permission_set_id: 'ps_other', organization_id: ORG }, + // An organization-less grant (it applies in every organization) on an organization's set. + { id: 'g_global_cross', user_id: 'usr_member', permission_set_id: 'ps_other' }, + { id: 'g_global_own', user_id: 'usr_member', permission_set_id: 'ps_own' }, + // Controls inside the wall: the organization's own set, and the organization-less platform row. + { id: 'g_own', user_id: 'usr_member', permission_set_id: 'ps_own', organization_id: ORG }, + { id: 'g_bucket', user_id: 'usr_member', permission_set_id: bucketViewer.id, organization_id: ORG }, + { id: 'g_global_bucket', user_id: 'usr_member', permission_set_id: bucketViewer.id }, + ]); + + const logger = newLogger(); + const outcome = await runOneTimeGrantPermissionSetNameBackfill(engine as any, { catalog, logger }); + + expect(outcome.backfill?.crossOrganization).toEqual(['g_cross', 'g_global_cross', 'g_global_own']); + for (const id of ['g_cross', 'g_global_cross', 'g_global_own']) { + expect((await grant(engine, id))?.permission_set, id).toBeNull(); + } + expect((await grant(engine, 'g_own'))?.permission_set).toBe(OWN_SET.name); + expect((await grant(engine, 'g_bucket'))?.permission_set).toBe('viewer_readonly'); + expect((await grant(engine, 'g_global_bucket'))?.permission_set).toBe('viewer_readonly'); + + const [, meta] = logger.warn.mock.calls.find(([m]) => String(m).includes('outside the grant'))!; + expect(meta).toMatchObject({ count: 3, grants: { ids: ['g_cross', 'g_global_cross', 'g_global_own'] } }); + const text = loggedText(logger); + expect(text).not.toContain(OTHER_SET.name); + expect(text).not.toContain(OTHER_ORG); + expect(text).not.toContain('ps_other'); + // Decided: nothing a later pass could name. + expect(outcome.status).toBe('ran'); + }); + } +}); + +describe('[ADR-0131 D4] grant name backfill — once, and remembered in sys_migration', () => { + it('a second pass writes nothing, and the ledger row is written once', async () => { + const world = await boot('single'); + await seedPrincipals(world, 'single'); + await clearAllNames(world.engine); + + const first = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { + catalog: world.catalog, logger: newLogger(), + }); + expect(first.status).toBe('ran'); + const [row] = await ledgerRows(world.engine); + expect(row).toMatchObject({ id: GRANT_SET_NAME_BACKFILL_MIGRATION_ID, blocking: 0 }); + expect(row.verified_at ?? null).toBeNull(); + expect(JSON.parse(row.details)).toEqual({ unnamed: 3, named: 3, dangling: 0, crossOrganization: 0 }); + + const update = vi.spyOn(world.engine, 'update'); + const insert = vi.spyOn(world.engine, 'insert'); + const find = vi.spyOn(world.engine, 'find'); + const second = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { + catalog: world.catalog, logger: newLogger(), + }); + expect(second).toEqual({ status: 'already-run', ledger: 'recorded' }); + expect(update).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + expect(find).not.toHaveBeenCalled(); + expect(await ledgerRows(world.engine)).toHaveLength(1); + }); + + it('with no ledger on the kernel the pass still names, says it cannot remember, and a second pass renames nothing', async () => { + const world = await boot('single', { ledger: false }); + await seedPrincipals(world, 'single'); + await clearAllNames(world.engine); + + const logger = newLogger(); + const first = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { catalog: world.catalog, logger }); + expect(first.status).toBe('ran-unrecorded'); + expect(first.ledger).toBe('unavailable'); + expect(first.backfill?.named.length).toBe(3); + expect(logger.warn.mock.calls.some(([m]) => String(m).includes('cannot record it'))).toBe(true); + + const update = vi.spyOn(world.engine, 'update'); + const second = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { + catalog: world.catalog, logger: newLogger(), + }); + expect(second.backfill?.unnamed).toBe(0); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe('[ADR-0131 D4] grant name backfill — the write path', () => { + it('the name lands with the S4a hooks unbound too: the backfill writes the name, it does not lean on the hook stamp', async () => { + const world = await boot('isolated'); + await seedPrincipals(world, 'isolated'); + await clearAllNames(world.engine); + unregisterGrantPermissionSetNameHooks(world.engine); + + const outcome = await runOneTimeGrantPermissionSetNameBackfill(world.engine as any, { + catalog: world.catalog, logger: newLogger(), + }); + expect(outcome.status).toBe('ran'); + for (const g of await grants(world.engine)) { + const [set] = (await world.engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS } as any)) as any[]; + expect(g.permission_set, g.id).toBe(set.name); + } + }); +}); + +describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { + it('SecurityPlugin runs it at kernel:bootstrapped, through the catalog read over the engine registry', async () => { + const world = await boot('single'); + await seedPrincipals(world, 'single'); + await clearAllNames(world.engine); + + const handlers = world.hooks.get('kernel:bootstrapped') ?? []; + expect(handlers).toHaveLength(1); + const errorsBefore = world.pluginLogger.error.mock.calls.length; + const warningsBefore = world.pluginLogger.warn.mock.calls.length; + await handlers[0](); + + expect((await grants(world.engine)).every((g) => typeof g.permission_set === 'string')).toBe(true); + expect(await ledgerRows(world.engine)).toHaveLength(1); + expect(world.pluginLogger.error.mock.calls.length).toBe(errorsBefore); + expect(world.pluginLogger.warn.mock.calls.length).toBe(warningsBefore); + }); +}); + +/** + * The resolver's envelope per principal — recorded from the tree BEFORE the + * name column existed (the S4a grant-equivalence golden), and unchanged by + * clearing the names or by the backfill: no reader reads the name yet. + */ +const GOLDEN: Record = { + single: { + agent: { + onBehalfOf: { principalKind: 'human', userId: 'usr_orgadmin' }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['organization_admin_no_bypass'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', + ], + }, + }, + isolated: { + agent: { + onBehalfOf: { principalKind: 'human', userId: 'usr_orgadmin' }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['organization_admin'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', + 'setup.access', 'setup.write', 'studio.access', 'view_all_audit_log', + ], + }, + }, +}; diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts index 1448f7cbbf4..a0576329b74 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts @@ -247,7 +247,8 @@ async function scanUnnamedGrants(engine: GrantNameBackfillEngine): Promise String(a.id).localeCompare(String(b.id))); + // Code-unit order — the order the driver's id sort gives, independent of locale. + return [...byId.values()].sort((a, b) => (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0)); } /** What the set row `setId` comes to for a grant of `grantOrganizationId`. */ From f74cf897de0a54177793086adc1804c4c04c5b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:42:51 +0000 Subject: [PATCH 3/8] fix(plugin-security): the backfill's logger type takes the kernel logger as it stands Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/grant-permission-set-name-backfill.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts index a0576329b74..b282e4a4c46 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts @@ -123,10 +123,10 @@ export interface GrantNameBackfillEngine { /** The kernel logger, as this module uses it. */ export interface GrantNameBackfillLogger { - info?: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; + info?: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; /** The kernel logger's shape: message, cause, meta. */ - error?: (message: string, cause?: unknown, meta?: Record) => void; + error?: (message: string, cause?: Error, meta?: Record) => void; } /** Why a pass stopped before it reached a verdict. */ From 686619a934d8a30705a145c9ad714b803d4b69da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:46:30 +0000 Subject: [PATCH 4/8] chore(changeset): plugin-security patch for the grant permission-set name backfill Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../15196-grant-permission-set-name-backfill.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/15196-grant-permission-set-name-backfill.md diff --git a/.changeset/15196-grant-permission-set-name-backfill.md b/.changeset/15196-grant-permission-set-name-backfill.md new file mode 100644 index 00000000000..0be497d8c7c --- /dev/null +++ b/.changeset/15196-grant-permission-set-name-backfill.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-security": patch +--- + +Grants written before `sys_user_permission_set.permission_set` existed now get their permission set's name, once, at boot (ADR-0131 D4) + +Clause-②: no + +- **What happens on the first boot after upgrading.** At `kernel:bootstrapped`, `@objectstack/plugin-security` fills `permission_set` on every grant that has no name yet. The name is the `name` of the `sys_permission_set` row that the grant's `permission_set_id` points at. The set row is read inside the grant's own organization: a grant of an organization may name that organization's set or an organization-less one, and an organization-less grant may name only an organization-less set. Each name is checked in the security catalog before it is written. Only the name column is written: no id changes, no grant is moved and no row is deleted. No principal's grants change, because readers still resolve grants from `permission_set_id`. +- **What is left unnamed, and reported in the boot log by count and grant id.** A grant whose id names no set row (`warn`). A grant whose id names another organization's set row (`warn`); nothing about that organization is logged. A grant whose set row carries a name the security catalog does not hold at that boot (`error`): register the permission set definition, or re-point the grant. +- **It runs once.** When the pass has nothing left that a later boot could decide differently, it records its verdict in `sys_migration` under the id `adr-0131-grant-permission-set-name-backfill`, and later boots skip it. A grant the catalog could not verify, or a write that did not land, leaves the verdict unrecorded, so the next boot tries again. Without a `sys_migration` table (no `PlatformObjectsPlugin` in the composition) the pass still runs on every boot, and renames nothing it already named. +- **Nothing to migrate.** No configuration is needed. From 06642523e12ab73757784dc985e833982b1ec876 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:19:16 +0000 Subject: [PATCH 5/8] test(plugin-security): the grant name backfill names a late kernel:ready provider's set on the same boot, on a real kernel The module doc records the measured write path (the S4a hooks judge the backfill's system write but stamp only a write carrying the id), the unwalled set-row read the resolver makes today, and why the pass runs without a ledger. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...grant-permission-set-name-backfill.test.ts | 77 +++++++++++++++++++ .../src/grant-permission-set-name-backfill.ts | 22 +++++- 2 files changed, 95 insertions(+), 4 deletions(-) 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 4a8d5109363..d14b688f4f0 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 @@ -20,6 +20,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { + LiteKernel, assembleExecutionContext, createSecurityCatalogReader, resetPlatformAdminEmailMemo, @@ -574,6 +575,82 @@ describe('[ADR-0131 D4] grant name backfill — the write path', () => { }); }); +describe('[ADR-0131 D4] grant name backfill — on a real kernel boot', () => { + it('a permission set a kernel:ready handler registers AFTER SecurityPlugin’s is named on the same boot, and the verdict is recorded', async () => { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: CATALOG_PACKAGE, + name: 'Grant name backfill', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMigration, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + for (const ps of defaultPermissionSets) { + engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); + } + // Stored before this boot: the set row, and a grant written before the + // name column existed (no SecurityPlugin has bound the name hooks yet). + await insertSet(engine, { id: 'ps_late', name: 'late_reviewer', label: 'Late' }); + await insertUser(engine, 'usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + await engine.insert( + 'sys_user_permission_set', + { id: 'g_late', user_id: 'usr_member', permission_set_id: 'ps_late' }, + { context: SYS } as any, + ); + expect((await grant(engine, 'g_late'))?.permission_set).toBeNull(); + + const metadata = { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets], + }; + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + // The engine's stand-in: SecurityPlugin depends on the engine plugin by name. + kernel.use({ + name: 'com.objectstack.engine.objectql', + init: async (ctx: any) => { + ctx.registerService('objectql', engine); + ctx.registerService('metadata', metadata); + ctx.registerService('manifest', { register: () => undefined }); + }, + start: async () => undefined, + } as any); + kernel.use(new SecurityPlugin({ fallbackPermissionSet: 'member_default' })); + // The late provider: its `kernel:ready` handler is registered after + // SecurityPlugin's own, so it runs after the platform bootstrap. + kernel.use({ + name: 'com.objectstack.qa.late-catalog', + dependencies: ['com.objectstack.security'], + init: async () => undefined, + start: async (ctx: any) => { + ctx.hook('kernel:ready', async () => { + engine.registry.registerItem( + 'permission', { name: 'late_reviewer', label: 'Late', objects: {} } as any, 'name' as any, CATALOG_PACKAGE, + ); + }); + }, + } as any); + try { + await kernel.bootstrap(); + expect((await grant(engine, 'g_late'))?.permission_set).toBe('late_reviewer'); + expect(await ledgerRows(engine)).toHaveLength(1); + } finally { + await kernel.shutdown(); + } + }); +}); + describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { it('SecurityPlugin runs it at kernel:bootstrapped, through the catalog read over the engine registry', async () => { const world = await boot('single'); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts index b282e4a4c46..b3a146534ab 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts @@ -35,7 +35,13 @@ * organization. The update hook re-derives the name from the row's stored * id through its own walled read and refuses a disagreement, so a grant * whose id moved between this pass's read and its write is refused, not - * mislabelled. + * mislabelled. The hook stamps a name by itself only on a write that + * carries `permission_set_id`; this write carries the verified name and no + * id, so nothing keyed on the id column (the last-administrator guard's + * standing keys among them) re-judges the grant. ⛔ The wall in step 1 is + * this pass's own: for a SYSTEM write whose stored id the hook cannot + * resolve inside the writer's wall, the hook stands down and lets the + * value land, so it would not stop a name carried across organizations. * * ## Report, never guess * @@ -46,7 +52,9 @@ * - **an id whose set row belongs to another organization** (`warn`) — its * name is never written: a name carries no organization, so writing another * organization's name would point the grant at whatever this organization - * calls by that name; + * calls by that name. The authorization resolver still reads a grant's set + * row by id without a wall, so such a grant grants by id today; the pass + * does not carry that answer into the name; * - **a set row whose name the catalog read does not resolve** (`error`) — * the grant points at a definition the catalog does not hold, so it would * fail closed once readers read the name. @@ -76,7 +84,9 @@ * Without a readable ledger the pass still runs — it only fills a `NULL` with * the name the row's own id already points at, so running it twice writes * nothing the first run did not — but its verdict cannot be remembered, and - * every boot scans again. + * every boot scans again. This is where it departs from the membership + * backfill, which does not run without its ledger because a second run of + * that pass would decide again what the first one decided. * * ## When it runs * @@ -84,7 +94,11 @@ * `kernel:ready` handler has settled — the platform bootstrap that seeds the * catalog rows, and the environment metadata hydrated before it — so the * catalog read sees every code and environment definition this boot will - * register. + * register, including one a provider registers from a `kernel:ready` handler + * that runs after this plugin's own. A definition that arrives later still (a + * package installed into the running process) cannot turn into a recorded + * "missing": an unresolved name leaves the verdict unrecorded, and the next + * boot judges it again. * * ADR anchors: ADR-0131 D4 (references by name), D10 (the id column is dropped * after a verified rewrite — not here), C7 (deletions — not here). From e4e24b7c23e9358c19f80ee87081d7f1fd9c2ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:31:21 +0000 Subject: [PATCH 6/8] chore(adr-anchors): anchor the grant name backfill to ADR-0131 Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...curity__src__grant-permission-set-name-backfill.ts.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__grant-permission-set-name-backfill.ts.json diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__grant-permission-set-name-backfill.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__grant-permission-set-name-backfill.ts.json new file mode 100644 index 00000000000..73b3033aad0 --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__grant-permission-set-name-backfill.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts", + "adrs": [ + "ADR-0131" + ], + "invariant": "A grant stored before its by-name column existed is given a name only when the name is the set row its own id names inside the grant's organization (that organization or the organization-less bucket) and the security catalog read resolves it (D4); an id naming no set row, another organization's set row, or a name the catalog does not resolve stays unnamed and is reported, never guessed. The pass is idempotent, records its verdict once in sys_migration, and deletes nothing: deletions are C7's and the id column is dropped only by D10's later step." +} From 788d6e2a2354b68cea93bf58072ecf129d1e4557 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:44:19 +0000 Subject: [PATCH 7/8] docs(tenant-audit-census): count the grant name backfill's two engine writes Regenerated with tenant-audit-census --write; the page's prose figures follow (233 to 235 write call sites, 78 to 80 undecidable, 123 to 124 decidably elevated, 102 to 103 elevation undecidable). Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 32 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 18 ++++++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 96cfa145fef..5167fb400cf 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **54 of the 233 sites are spelled that way**. A +forwarding shim cannot, and **54 of the 235 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **233** | +| 175 write call sites | quoted in the merged changeset | **235** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 235** decidable, **80** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 124 decidably elevated, 0 decidably not, 103 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 233 (53%) as decidably elevated, with 102 more whose elevation is a +124 of 235 (53%) as decidably elevated, with 103 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 235`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,23 +223,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **233** | +| write call sites on the application surface | **235** | | …whose object name is statically decidable | 155 | -| …whose object name is chosen at run time | 78 | +| …whose object name is chosen at run time | 80 | | …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 173 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 54 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 124 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 102 | +| threading a context whose elevation is a run-time fact | 103 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 185 | +| receiver carried a readable engine type | 187 | | receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -247,7 +247,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 104 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 19 | -| object name is some other run-time expression | 59 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-08 at `6eabe4b41`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 612 | -| engine-shaped types recognised | 70 | +| tracked non-test sources scanned | 614 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index abbf102cbbb..121f20e347f 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 233 | +| Write call sites | 235 | | Object name statically decidable | 155 | -| Object name chosen at run time | 78 | +| Object name chosen at run time | 80 | | Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 173 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 54 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 124 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 102 | +| Threading a context of undecidable elevation | 103 | ## Subtractions the census could NOT defend — enforced @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-08 at `6eabe4b41`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 612 | -| engine-shaped types recognised | 70 | +| tracked non-test sources scanned | 614 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | @@ -168,6 +168,8 @@ Measured on 2026-10-07 at `e1171ca48`. | `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | +| `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts` | `delete` | `sys_metadata` | enabled | context, elevation undecidable | 1 | From 7f9500afd59b37e13f5bd4970b3fa69a2e7ee219 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:22:06 +0000 Subject: [PATCH 8/8] test(plugin-security): the backfill pins read through typed engine query options Ten read sites erased their options bag to any; the query-options erasure ratchet counted them (test surface 236 to 246). Typed against the engine's own find / findOne signature now, and the count is back at 236. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...grant-permission-set-name-backfill.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 d14b688f4f0..012a9f998e9 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 @@ -212,7 +212,7 @@ async function insertUnnamedGrants(engine: Engine, rows: Array { unregisterGrantPermissionSetNameHooks(engine); try { - const rows = (await engine.find('sys_user_permission_set', { fields: ['id'], context: SYS } as any)) as any[]; + const rows = (await engine.find('sys_user_permission_set', { fields: ['id'], context: SYS })) as any[]; for (const row of rows) { await engine.update('sys_user_permission_set', { id: row.id, permission_set: null }, { context: SYS } as any); } @@ -226,19 +226,19 @@ async function grants(engine: Engine): Promise>> { fields: ['id', 'user_id', 'permission_set_id', 'permission_set', 'organization_id'], orderBy: [{ field: 'id', order: 'asc' }], context: SYS, - } as any)) as any[]; + })) as any[]; return rows; } async function grant(engine: Engine, id: string): Promise | null> { - return (await engine.findOne('sys_user_permission_set', { where: { id }, context: SYS } as any)) as any; + return (await engine.findOne('sys_user_permission_set', { where: { id }, context: SYS })) as any; } async function ledgerRows(engine: Engine): Promise>> { return (await engine.find(DATA_MIGRATION_FLAG_OBJECT, { where: { id: GRANT_SET_NAME_BACKFILL_MIGRATION_ID }, context: SYS, - } as any)) as any[]; + })) as any[]; } /** Every string a logger mock was handed — message and meta — for disclosure checks. */ @@ -272,7 +272,7 @@ async function seedPrincipals(world: World, posture: Posture): Promise { if (posture === 'isolated') { for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = (await engine.find('sys_permission_set', { where: { name }, context: SYS } as any)) as any[]; + const [bucket] = (await engine.find('sys_permission_set', { where: { name }, context: SYS })) as any[]; const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket; await insertSet(engine, { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, ORG); } @@ -288,7 +288,7 @@ async function seedPrincipals(world: World, posture: Posture): Promise { const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, context: SYS, - } as any)) as any[]; + })) as any[]; await engine.insert( 'sys_user_permission_set', { user_id: 'usr_member', permission_set_id: viewer.id }, @@ -339,7 +339,7 @@ describe('[ADR-0131 D4] grant name backfill — every name agrees with the id an // the shape grants written before the per-organization catalog carry. const [bucketViewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly', organization_id: null }, context: SYS, - } as any)) as any[]; + })) as any[]; await insertUnnamedGrants(engine, [ { id: 'g_bucket', user_id: 'usr_orgadmin', permission_set_id: bucketViewer.id, organization_id: ORG }, ]); @@ -354,7 +354,7 @@ describe('[ADR-0131 D4] grant name backfill — every name agrees with the id an expect(outcome.backfill?.named.length).toBe(before.length); for (const g of await grants(engine)) { - const [set] = (await engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS } as any)) as any[]; + const [set] = (await engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS })) as any[]; expect(g.permission_set, g.id).toBe(set.name); const entry = await catalog.resolve('permission', g.permission_set); expect(entry?.name, g.id).toBe(g.permission_set); @@ -472,7 +472,7 @@ describe('[ADR-0131 D4] grant name backfill — a name never crosses organizatio await insertSet(engine, { id: 'ps_own', name: OWN_SET.name, label: 'Reviewer' }, ORG); const [bucketViewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly', organization_id: null }, context: SYS, - } as any)) as any[]; + })) as any[]; await insertUnnamedGrants(engine, [ // The organization's grant on the other organization's set. { id: 'g_cross', user_id: 'usr_member', permission_set_id: 'ps_other', organization_id: ORG }, @@ -569,7 +569,7 @@ describe('[ADR-0131 D4] grant name backfill — the write path', () => { }); expect(outcome.status).toBe('ran'); for (const g of await grants(world.engine)) { - const [set] = (await world.engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS } as any)) as any[]; + const [set] = (await world.engine.find('sys_permission_set', { where: { id: g.permission_set_id }, context: SYS })) as any[]; expect(g.permission_set, g.id).toBe(set.name); } });