From 96ede1f24fe799c1a2d773bd119e329d512938ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:36:07 +0000 Subject: [PATCH 1/7] wip(plugin-security): delete the built-in and declared position seeders and the everyone baseline junction writer Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .../src/bootstrap-builtin-positions.ts | 166 ------- .../src/bootstrap-declared-positions.test.ts | 410 ------------------ .../src/bootstrap-declared-positions.ts | 391 ----------------- .../plugin-security/src/security-plugin.ts | 128 +----- 4 files changed, 10 insertions(+), 1085 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-positions.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts deleted file mode 100644 index 61cb08cff8a..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapBuiltinRoles — seed the framework's reserved built-in identity roles - * (ADR-0068 D2) into `sys_position`. - * - * The four built-in roles (`platform_admin`, `org_owner`, `org_admin`, - * `org_member`) are a normalized PROJECTION surfaced in `current_user.positions`. - * Seeding their `sys_position` rows makes the role catalog (consumed by role-bound - * permission sets, sharing-rule recipients, and the ADR-0068 D4 role-catalog - * validator) self-describing and AI-groundable. Their SOURCES OF TRUTH — - * `sys_member.role` for the org_* roles and the unscoped `admin_full_access` - * grant for platform_admin — are NEVER changed by this seed. - * - * Idempotent upsert by `(name, organization_id)`, no prune. Rows are stamped - * `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') so - * tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside the - * platform-admin and declared-role bootstraps, and again for each organization - * as it is created. - * - * ## Per organization under a walled posture - * - * The built-in names are seeded PER ORGANIZATION, copies and all. That is the - * ruled reading of what these rows are: `sys_position` spells its name index - * `unique: 'organization'`, `sys_user_position` assignments are already - * per-organization, and a walled tenant that cannot SEE `everyone` cannot bind - * anything to it. What is not copied is the SOURCE OF TRUTH behind the names — - * `sys_member.role` for the org_* roles and the unscoped `admin_full_access` - * grant for `platform_admin` — exactly as before: this seed remains a catalog - * projection, so per-organization copies of the catalog change no derivation. - * - * A `single`-posture deployment keeps exactly one organization-less pass. See - * `per-organization-catalog.ts` for the doctrine and for the loud guard that - * stands in place of a reap. - * - * ## The names come from the declarations - * - * The six are declared position metadata of this plugin (ADR-0131 D2; - * `builtin-positions.ts` holds the one list, which the plugin also registers - * with the engine registry). This seed reads that list — it has no list of its - * own — and writes the same rows it always has. `bootstrapDeclaredPositions` - * skips the six, so this remains their only writer. - */ - -import { securityBuiltinPositions } from './builtin-positions.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedLogger, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { - resolveOwnOrganizationRow, - seedCtx, - warnOrganizationLessRows, -} from './organization-scope.js'; -import { - rowMatchesDeclaration, -} from './per-organization-catalog.js'; - -function genId(prefix: string): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `${prefix}_${ts}${rand}`; -} - -async function tryFind(ql: any, object: string, where: any, limit = 100, organizationId?: string): Promise { - try { - const rows = await ql.find(object, { where, limit }, { context: seedCtx(organizationId) }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } -} -// ⛔ The `catch` RECORDS before it answers — see the sibling in -// `bootstrap-declared-positions.ts`. Answering `null`/`false` alone is what -// made a refused write indistinguishable from "nothing to do". -async function tryInsert( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - return await ql.insert(object, data, { context: seedCtx(organizationId) }); - } catch (e) { refusals?.record(object, e); return null; } -} -async function tryUpdate( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: seedCtx(organizationId) }); return true; - } catch (e) { refusals?.record(object, e); return false; } -} - -interface SeedOptions { - logger?: { - info: (m: string, meta?: Record) => void; - warn: (m: string, meta?: Record) => void; - /** - * Durability channel for a catalog write that was refused — see - * {@link SeedLogger.error} for the signature and why it is optional. - * Declared here so the level this seeder reaches for is visible in its - * own options rather than only inside the reporter; absent, the report - * falls back to `warn` and is never dropped. - */ - error?: SeedLogger['error']; - }; - /** - * Seed THIS organization's copies. Omitted = the `single`-posture pass, the - * one place an organization-less catalog row is the correct shape. - */ - organizationId?: string; -} - -export async function bootstrapBuiltinRoles( - ql: any, - options: SeedOptions = {}, -): Promise<{ seeded: number; updated: number }> { - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, updated: 0 }; - } - const organizationId = options.organizationId; - let seeded = 0; - let updated = 0; - let unchanged = 0; - const residue: string[] = []; - // One log per pass, not per refused row (see the sibling seeders). - const refusals = createSeedWriteRefusals(); - // [ADR-0131 C2 S2] The ONE list of the six — the same declarations the - // plugin registers as position metadata (`builtin-positions.ts`). This seed - // writes exactly what it wrote from its own code list before. - const rows = securityBuiltinPositions; - for (const { name, ...meta } of rows) { - // [A4 #2920] Unified provenance vocab: built-in identity/anchor positions are - // PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so - // legacy 'system' rows self-heal to 'platform' on the next kernel:ready. - const fields = { label: meta.label, description: meta.description, managed_by: 'platform' }; - // Limit 5, not 1: a tenant-scoped read passes through `applyTenantScope`, - // whose compatibility arm returns organization-less rows alongside this - // organization's own. Asking for one row would hand back whichever the - // driver ordered first — and taking a pre-fix organization-less row as - // "already seeded" is exactly the silent no-op this pass must not perform. - const existing = await tryFind(ql, 'sys_position', { name }, 5, organizationId); - const { own, organizationLessResidue } = resolveOwnOrganizationRow(existing, organizationId); - if (organizationLessResidue) residue.push(name); - if (own?.id) { - // O(changed declarations): an unchanged row costs no write at all. - if (rowMatchesDeclaration(own, fields)) { unchanged += 1; continue; } - if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId, refusals)) updated += 1; - } else { - const created = await tryInsert(ql, 'sys_position', { - id: genId('position'), name, ...fields, active: true, is_default: false, - }, organizationId, refusals); - if (created) seeded += 1; - } - } - if (organizationId) { - // See the sibling in `bootstrap-declared-positions.ts`: no organization-less - // writer survives for `sys_position`, so no platform bucket is declared. - warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId); - } - // Before the counts, so an operator reads WHY the count is zero beside it. - reportSeedWriteRefusals(options.logger, refusals, organizationId); - if (seeded + updated > 0) { - options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', { - seeded, updated, unchanged, total: rows.length, ...(organizationId ? { organization: organizationId } : {}), - }); - } - return { seeded, updated }; -} diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts deleted file mode 100644 index 34c1dbbcb2a..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#2909 T2] Lock the seed semantics of bootstrapDeclaredPositions. - * - * sys_position is RECORD-AUTHORITATIVE (ADR-0094 addendum): the declared - * `positions: []` metadata seeds row IDENTITY + display fields only. The - * record side — permission-set bindings, `active`, `is_default`, - * `delegatable`, `managed_by` provenance — belongs to the runtime/admin and - * must never be touched by a re-seed. These tests exist to keep that - * contract from regressing silently (the behavior predates them but was - * never locked). - */ - -import { describe, it, expect } from 'vitest'; -import { SchemaRegistry } from '@objectstack/objectql'; -import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; -import { registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js'; -import { SECURITY_PLUGIN_ID } from './manifest.js'; - -/** - * The metadata service of a deployment that declares no position in it — the - * catalog read takes both sources and refuses to be built over a missing one. - */ -const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; - -/** - * A registry over a fixed list, as the catalog read uses one: its list, its - * by-name read (first match), and no disabled package. - */ -function listRegistry(items: () => any[]) { - return { - listItems: (type: string) => (type === 'position' ? [...items()] : []), - getItem: (type: string, name: string) => (type === 'position' ? items().find((i) => i?.name === name) : undefined), - isPackageDisabled: () => false, - }; -} - -/** A metadata service holding `items` as its declared positions. */ -const metadataListing = (items: any[]) => ({ - get: async (type: string, name: string) => (type === 'position' ? items.find((i) => i.name === name) : undefined), - list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []), -}); - -/** - * Minimal in-memory ql for sys_position seeding. `registry` replaces the - * list-backed one when a case needs the real engine registry's by-name - * precedence. - */ -function makeQl(declared: any[] = [], registry?: unknown) { - const rows: any[] = []; - return { - rows, - // [#8378] Items are surfaced as the real engine surfaces them — the - // document itself, not a `{ content: }` box that nothing produces. - registry: registry ?? listRegistry(() => declared), - async find(object: string, q: any) { - if (object !== 'sys_position') return []; - const where = q?.where ?? {}; - // Membership is modelled because the real engine supports it and the - // #10946 boot seeders now hoist ONE `$in` existence read out of their - // loop. A double that silently answered `[]` to `$in` would report - // "nothing is seeded" and make every re-seed look like a first boot. - return rows.filter((r) => 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)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return r[k] === v; - })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_position') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_position') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', () => { - it('inserts new declared positions with identity + display fields only', async () => { - const ql = makeQl([{ name: 'contributor', label: 'Contributor', description: 'Does work' }]); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r.seeded).toBe(1); - const row = ql.rows[0]; - expect(row).toMatchObject({ name: 'contributor', label: 'Contributor', active: true, is_default: false }); - // No code package holds this name (the list registry names none), so the - // row gets no provenance stamp and carries the object default. The stamp - // a package-held name gets is pinned in the #22360 block below. - expect(row.managed_by).toBeUndefined(); - }); - - it('refreshes ONLY label/description on existing rows', async () => { - const ql = makeQl([{ name: 'contributor', label: 'Contributor v2', description: 'new text' }]); - ql.rows.push({ - id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old', - active: true, is_default: false, - }); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r.updated).toBe(1); - expect(ql.rows[0].label).toBe('Contributor v2'); - expect(ql.rows[0].description).toBe('new text'); - }); - - it('NEVER touches authoritative record fields (active/is_default/delegatable/managed_by)', async () => { - const ql = makeQl([{ name: 'contributor', label: 'Contributor v2' }]); - // Admin turned the position off, made it default, marked it delegatable, - // and the row carries provenance — a re-seed must not reset any of it. - ql.rows.push({ - id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old', - active: false, is_default: true, delegatable: true, managed_by: 'package', - permissions: ['something_admin_set'], - }); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - const row = ql.rows[0]; - expect(row.active).toBe(false); - expect(row.is_default).toBe(true); - expect(row.delegatable).toBe(true); - expect(row.managed_by).toBe('package'); - expect(row.permissions).toEqual(['something_admin_set']); - // …while the display fields did refresh. - expect(row.label).toBe('Contributor v2'); - }); - - it('is idempotent — a re-run inserts nothing new', async () => { - const ql = makeQl([{ name: 'a', label: 'A' }, { name: 'b', label: 'B' }]); - const r1 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r1.seeded).toBe(2); - const r2 = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r2.seeded).toBe(0); - expect(ql.rows).toHaveLength(2); - }); -}); - -/** - * [ADR-0131 C2 S2] The six built-in positions are declared metadata, which the - * catalog read lists; they stay `bootstrapBuiltinRoles`'s rows — this seeder - * writes no copy of them and refreshes none of their columns. - */ -describe('bootstrapDeclaredPositions — the six built-in positions are not this seeder’s', () => { - const builtins = () => securityBuiltinPositions.map((p) => ({ ...p })); - - it('seeds the metadata service’s positions while the registry holds the six', async () => { - const ql = makeQl(builtins()); - const r = await bootstrapDeclaredPositions( - ql, - metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]), - ); - expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'field_rep']); - expect(r).toEqual({ seeded: 2, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => { - const ql = makeQl(builtins()); - const r = await bootstrapDeclaredPositions(ql, metadataListing(builtins()), { organizationId: 'org_a' }); - expect(ql.rows).toEqual([]); - expect(r).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - it('refreshes nothing on the six’s rows, whatever a declaration of the same name says', async () => { - // The rows as `bootstrapBuiltinRoles` leaves them… - const seeded = securityBuiltinPositions.map((p, i) => ({ - id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description, - managed_by: 'platform', active: true, is_default: false, - })); - // …and a declaration of each name whose display text differs from them. - const drifted = securityBuiltinPositions.map((p) => ({ name: p.name, label: `${p.label} (redeclared)` })); - const ql = makeQl([...drifted, { name: 'door_authored', label: 'Door Authored' }]); - ql.rows.push(...seeded.map((row) => ({ ...row }))); - const r = await bootstrapDeclaredPositions(ql, metadataListing(drifted)); - expect(ql.rows.filter((row) => row.name !== 'door_authored')).toEqual(seeded); - expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); - }); -}); - -/** - * [ADR-0131 C2 S2b] The seeder reads through the security catalog read — the - * engine registry and the metadata service, in its one read order — where it - * used to take the registry ALONE whenever the registry held any position - * besides the six. - * - * The registry here is the real `SchemaRegistry`, so a name's answer is the - * registry's own by-name precedence, not a fixture's: the six registered the - * way the plugin registers them (packaged, composite keys), and a definition a - * metadata author saved hydrated the way the door hydrates one (no package, the - * bare slot). - */ -describe('bootstrapDeclaredPositions — one catalog read, both sources (ADR-0131 C2 S2b)', () => { - const declaredRegistry = (...authored: Array>) => { - const registry = new SchemaRegistry(); - for (const item of authored) registry.registerItem('position', { ...item }, 'name'); - registerBuiltinPositions(registry, SECURITY_PLUGIN_ID); - return registry; - }; - const byName = (rows: any[]) => Object.fromEntries(rows.map((row) => [row.name, row])); - - it('a door-authored position no longer silences the stack’s declared positions', async () => { - const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' })); - const r = await bootstrapDeclaredPositions( - ql, - metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]), - { organizationId: 'org_late' }, - ); - expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'door_authored', 'field_rep']); - expect(r).toEqual({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - it('writes the door-authored position’s row as the registry-only read wrote it', async () => { - const ql = makeQl([], declaredRegistry({ name: 'door_authored', label: 'Door Authored' })); - await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }])); - const { id, ...row } = byName(ql.rows).door_authored; - expect(id).toMatch(/^position_/); - expect(row).toEqual({ name: 'door_authored', label: 'Door Authored', description: null, active: true, is_default: false }); - }); - - // The read order, measured: for a name BOTH sources hold, the registry's body - // is the one written — what the either-or wrote too, because the registry - // holding that name was itself what made the registry answer. - it('a name both sources hold is written from the registry’s body', async () => { - const ql = makeQl([], declaredRegistry({ name: 'field_rep', label: 'Field Rep (saved at the door)', description: 'Door text' })); - const r = await bootstrapDeclaredPositions( - ql, - metadataListing([{ name: 'field_rep', label: 'Field Rep', description: 'Declared text' }]), - ); - expect(ql.rows.map((row) => [row.name, row.label, row.description])).toEqual([ - ['field_rep', 'Field Rep (saved at the door)', 'Door text'], - ]); - expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - // A definition saved under a built-in name before the six were declared is - // hydrated into the bare slot and shadows the declaration at read. Positive - // control first: the registry really answers the stored body for the name. - it('a stored definition shadowing a built-in name is neither seeded nor restamped', async () => { - // Both hydration shapes: stated tenant-authored only (hydrated before the - // six were registered), and with the declaration's envelope grafted on - // (hydrated after) — which names THIS plugin's package. - const shadows = [ - { name: 'org_admin', label: 'Repurposed Org Admin', description: 'Saved at the door', _provenance: 'org' }, - { name: 'everyone', label: 'Repurposed Everyone', description: 'Saved at the door', _provenance: 'org', _packageId: SECURITY_PLUGIN_ID }, - ]; - const registry = declaredRegistry(...shadows); - expect((registry.getItem('position', 'org_admin') as any)?.label).toBe('Repurposed Org Admin'); - expect((registry.getItem('position', 'everyone') as any)?.label).toBe('Repurposed Everyone'); - - const seeded = securityBuiltinPositions.map((p, i) => ({ - id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description, - managed_by: 'platform', active: true, is_default: false, - })); - const fresh = makeQl([], registry); - const onFresh = await bootstrapDeclaredPositions(fresh, NO_METADATA_POSITIONS, { organizationId: 'org_a' }); - expect(fresh.rows).toEqual([]); - expect(onFresh).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); - - const existing = makeQl([], registry); - existing.rows.push(...seeded.map((row) => ({ ...row }))); - const onExisting = await bootstrapDeclaredPositions(existing, NO_METADATA_POSITIONS); - expect(existing.rows).toEqual(seeded); - expect(onExisting).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - // A read that did not happen is not "nothing declared": the seeder writes - // nothing and the failure reaches its caller, which reports it. - it('writes nothing when a catalog source cannot be read, and says so', async () => { - const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); - (ql.registry as any).listItems = () => { throw new Error('registry unreachable'); }; - const failure = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS).then( - () => undefined, - (e: any) => ({ code: e?.code, status: e?.status }), - ); - expect(failure).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 }); - expect(ql.rows).toEqual([]); - - const degraded = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); - const lostLoader = { - ...NO_METADATA_POSITIONS, - listDiagnosed: async () => ({ items: [{ name: 'auditor', label: 'Auditor' }], degraded: true, errors: ['loader down'] }), - }; - const partial = await bootstrapDeclaredPositions(degraded, lostLoader).then( - () => undefined, - (e: any) => ({ code: e?.code, status: e?.status }), - ); - expect(partial).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 }); - expect(degraded.rows).toEqual([]); - }); - - it('refuses a composition with no metadata service rather than reading the registry alone', async () => { - const ql = makeQl([{ name: 'field_rep', label: 'Field Rep' }]); - const failure = await bootstrapDeclaredPositions(ql, null).then(() => undefined, (e: unknown) => e); - expect(failure).toBeInstanceOf(TypeError); - expect(ql.rows).toEqual([]); - }); -}); - -/** - * [#22360] The row of a position a code package holds carries the package's - * provenance, so the system-row write gate refuses an admin-door edit of it — - * as the metadata door already refuses one (ADR-0131 D6). Before this the row - * carried the object default (`admin`): a data-door label edit answered 200 - * and the next boot wrote the declaration back over it. - * - * The registry is the real `SchemaRegistry`, so "a package holds the name" is - * the registry's own artifact lookup — the one the metadata door refuses a - * save from — not a fixture's opinion. - */ -describe('bootstrapDeclaredPositions — package provenance on a package-held name (#22360)', () => { - const PKG = 'com.example.pkg'; - const packaged = (...items: Array>) => { - const registry = new SchemaRegistry(); - for (const item of items) registry.registerItem('position', { ...item }, 'name', PKG); - return registry; - }; - /** The payloads the pass handed to `update`, as it handed them. */ - const recordUpdates = (ql: ReturnType) => { - const sent: any[] = []; - const update = ql.update.bind(ql); - ql.update = async (object: string, data: any) => { sent.push({ object, data: { ...data } }); return update(object, data); }; - return sent; - }; - - it('stamps a new row package-managed', async () => { - const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep' })); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(ql.rows.map((row) => [row.name, row.managed_by])).toEqual([['field_rep', 'package']]); - expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); - }); - - it('corrects an upgraded deployment\'s admin-stamped row in place: managed_by and nothing else', async () => { - const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep', description: 'In the field' })); - // The row a pre-fix boot wrote, with the columns an administrator set since. - const before = { - id: 'pos_1', name: 'field_rep', label: 'Field Rep', description: 'In the field', - managed_by: 'admin', active: false, is_default: true, delegatable: true, - }; - ql.rows.push({ ...before }); - const sent = recordUpdates(ql); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(sent).toEqual([{ object: 'sys_position', data: { id: 'pos_1', managed_by: 'package' } }]); - expect(ql.rows).toEqual([{ ...before, managed_by: 'package' }]); - expect(r).toEqual({ seeded: 0, updated: 1, unchanged: 0, unreadable: 0 }); - }); - - it('refreshes drifted display text and corrects the stamp in one write', async () => { - const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep v2' })); - ql.rows.push({ id: 'pos_1', name: 'field_rep', label: 'Field Rep', description: null, managed_by: 'admin' }); - const sent = recordUpdates(ql); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(sent.map((s) => s.data)).toEqual([ - { id: 'pos_1', label: 'Field Rep v2', description: null, managed_by: 'package' }, - ]); - }); - - it('a stamped row is left alone by the next pass', async () => { - const ql = makeQl([], packaged({ name: 'field_rep', label: 'Field Rep' })); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - const sent = recordUpdates(ql); - const again = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(sent).toEqual([]); - expect(again).toEqual({ seeded: 0, updated: 0, unchanged: 1, unreadable: 0 }); - }); - - it('a row the write gate already treats as managed keeps its value', async () => { - const names = ['kept_platform', 'kept_system', 'kept_config']; - const ql = makeQl([], packaged(...names.map((name) => ({ name, label: name })))); - ql.rows.push( - { id: 'pos_p', name: 'kept_platform', label: 'kept_platform', description: null, managed_by: 'platform' }, - { id: 'pos_s', name: 'kept_system', label: 'kept_system', description: null, managed_by: 'system' }, - { id: 'pos_c', name: 'kept_config', label: 'kept_config', description: null, managed_by: 'config' }, - ); - const sent = recordUpdates(ql); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(sent).toEqual([]); - expect(ql.rows.map((row) => row.managed_by)).toEqual(['platform', 'system', 'config']); - }); - - // Control: a position the environment authored through the metadata door is - // hydrated into the registry with no package, and that door lets its author - // edit it — so its row stays unmanaged, exactly as before. - it('control: an environment-authored definition keeps an unmanaged row', async () => { - const registry = new SchemaRegistry(); - registry.registerItem('position', { name: 'door_authored', label: 'Door Authored' }, 'name'); - registry.registerItem('position', { name: 'door_kept', label: 'Door Kept' }, 'name'); - const ql = makeQl([], registry); - ql.rows.push({ id: 'pos_k', name: 'door_kept', label: 'Door Kept', description: null, managed_by: 'admin' }); - const sent = recordUpdates(ql); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(sent).toEqual([]); - const byName = Object.fromEntries(ql.rows.map((row) => [row.name, row.managed_by])); - expect(byName).toEqual({ door_kept: 'admin', door_authored: undefined }); - }); - - // A registry without the artifact lookup is asked the way the metadata door - // asks one: the definition names a package, and not the rehydration sentinel. - it('a registry without the artifact lookup: a named package stamps, the sys_metadata sentinel does not', async () => { - const ql = makeQl([ - { name: 'shipped', label: 'Shipped', _packageId: PKG }, - { name: 'rehydrated', label: 'Rehydrated', _packageId: 'sys_metadata' }, - ]); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - const byName = Object.fromEntries(ql.rows.map((row) => [row.name, row.managed_by])); - expect(byName).toEqual({ shipped: 'package', rehydrated: undefined }); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts deleted file mode 100644 index bd0f42e30d2..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredPositions — seed stack-declared `positions` into `sys_position` - * (ADR-0057 D6, closes #2077). - * - * Reads the declared `position` metadata through the security catalog read - * (`createSecurityCatalogReader`, `@objectstack/core` — the engine registry and - * the metadata service, in its one read order; see {@link readDeclaredPositions}) - * and idempotently upserts each into - * `sys_position` by `(name, organization_id)`, so the runtime position→permission-set resolution - * (`resolveExecutionContext` → `sys_position` → `sys_position_permission_set`) and - * sharing-rule position recipients stop being decorative. Runs on `kernel:ready` - * alongside the platform-admin bootstrap. - * - * Pre-launch posture (ADR-0057): upsert only — no prune. Position visibility - * HIERARCHY is NOT seeded here: per ADR-0057 D5 the position is a capability - * bundle, and "manager sees subordinates" lives on the `sys_business_unit` - * tree, not `sys_position.parent`. - * - * [#10103] ONE PASS PER ORGANIZATION under a walled posture: the object spells - * its name index `unique: 'organization'`, and a row belonging to no - * organization is invalid state there — it was measured unreadable by every - * principal, because Layer 0's strict `organization_id = :tenant` AND-composes - * over the driver's compatibility arm and leaves the strict equality alone. - * `single` posture keeps exactly one organization-less pass. Doctrine, and the - * loud guard that stands in place of a reap: `per-organization-catalog.ts`. - * - * [#22360] PROVENANCE: the row of a position a code package holds is stamped - * `managed_by: 'package'` — on insert, and on an existing row that lacks a - * managed value — so the system-row write gate refuses an admin-door edit of - * its definition, or a delete of it, as the metadata door already refuses one - * (ADR-0131 D6). Its row state (`active`, `is_default`) stays switchable, as a - * packaged permission set's does — carve-out (d) of the gate in - * `security-plugin.ts`. See {@link packageProvenanceStamp}. - */ - -import { createSecurityCatalogReader } from '@objectstack/core'; -import { buildExistingByName } from './seed-name-lookup.js'; -import { isBuiltinPositionName } from './builtin-positions.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedLogger, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { - seedCtx, - warnOrganizationLessRows, -} from './organization-scope.js'; - -function genId(prefix: string): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `${prefix}_${ts}${rand}`; -} - -// ⛔ The `catch` RECORDS before it answers. Answering `null`/`false` alone is -// what made a refused INSERT indistinguishable from "nothing to do": `seeded` -// never increments, the pass returns normally, and the boot logs a successful -// seed of zero rows. The refusal log is what carries the signal past this -// frame — see `reportSeedWriteRefusals`. Still no rethrow: the pass reports and -// continues, it does not decide whether the deployment boots. -async function tryInsert( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - return await ql.insert(object, data, { context: seedCtx(organizationId) }); - } catch (e) { refusals?.record(object, e); return null; } -} -async function tryUpdate( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: seedCtx(organizationId) }); return true; - } catch (e) { refusals?.record(object, e); return false; } -} - -interface SeedOptions { - logger?: { - info: (m: string, meta?: Record) => void; - warn: (m: string, meta?: Record) => void; - /** - * Durability channel for a catalog write that was refused — see - * {@link SeedLogger.error} for the signature and why it is optional. - * Declared here so the level this seeder reaches for is visible in its - * own options rather than only inside the reporter; absent, the report - * falls back to `warn` and is never dropped. - */ - error?: SeedLogger['error']; - }; - /** - * Seed THIS organization's copies. Omitted = the `single`-posture pass, the - * one place an organization-less catalog row is the correct shape. - */ - organizationId?: string; -} - -/** A declared position this seeder owns: any name but the six built-ins. */ -function isSeededHere(item: { name?: unknown } | undefined): boolean { - return !isBuiltinPositionName(item?.name); -} - -/** - * The positions this seeder projects into rows: every position the security - * catalog read lists, minus the six built-ins (ADR-0131 C2 stage S2b). - * - * ## One read, both sources (ADR-0131 D3) - * - * The catalog read (`createSecurityCatalogReader`, `@objectstack/core`) is the - * union of the engine registry and the metadata service, in its one read - * order: a name the registry serves is answered by the registry's own by-name - * precedence (a stored definition in the bare slot first, else the - * first-registered package's), and the metadata service answers only the names - * the registry does not serve. Neither source holds the whole catalog — the - * stack-declared positions live in the metadata service, a definition a - * metadata author saved through the door is hydrated into the registry — so - * this seeder reads both, every pass. - * - * It replaces an either-or: the registry alone whenever it held any position - * besides the six, else the metadata service. One door-authored position was - * then enough to make the registry answer alone, and every organization - * created afterwards was seeded with that position and none of the stack's. - * For a name both sources hold, the answer is unchanged: the either-or took - * the registry's body for it, and so does the catalog read. - * - * ## Why the six are taken out - * - * Their rows are `bootstrapBuiltinRoles`'s, seeded from the declaration list - * (`builtin-positions.ts`) with the `platform` provenance this seeder never - * writes. The catalog read lists them — the plugin registers them with the - * engine registry on every boot — so taking them here would put a copy without - * that provenance ahead of the built-in pass on a fresh organization (refused - * outright for a reserved identity name), which the built-in pass then - * restamps: a second writer for six rows that have one. The exclusion is by - * NAME, never by package: an environment-stored definition saved under a - * built-in name before the six were declared is hydrated into the registry's - * bare slot with no package of this plugin's, and shadows the declaration at - * read. It is skipped here like the declaration it shadows, so its body never - * reaches a row. - * - * ## A read that did not happen is not "nothing declared" - * - * The catalog read refuses to be built over a source that lacks a member it - * calls (`TypeError`) and raises `AuthzStoreUnavailableError` for a source that - * threw or a metadata list that lost a loader. Both propagate: this seeder - * writes nothing for the pass rather than seeding a partial catalog as a whole - * one, and the caller reports the failure. - * - * [#8378] No `{ name, content }` unwrap: the catalog entry's `definition` IS the - * authoring document — see `bootstrap-declared-permissions.ts`. It is the - * reader's own object, shared with every other reader, and is only read here. - * - * [#22360] Each definition comes back with whether a code package holds its - * name ({@link packageHoldsName}), asked here, before the pass writes - * anything, so a registry that cannot answer fails the pass the way an - * unreadable catalog does instead of failing it half-way through. - */ -async function readDeclaredPositions( - engine: any, - metadataService: any, -): Promise> { - const catalog = createSecurityCatalogReader({ registry: engine?.registry, metadata: metadataService }); - const listed = await catalog.list('position'); - return listed.filter(isSeededHere).map((entry) => ({ - definition: entry.definition, - packageHeld: packageHoldsName(engine?.registry, entry), - })); -} - -/** - * The columns a re-seed writes. Position IDENTITY + display only: the record - * side (bindings, `active`, `is_default`, `delegatable`) belongs to the - * runtime and is never projected from the declaration (#2909 T2). - * - * `managed_by` is not projected either — a position declaration has no such - * key. It is written separately, by {@link packageProvenanceStamp}, and only - * to record WHO holds the name. - */ -function positionRowFields(r: any): { label: any; description: any } { - return { label: r.label ?? r.name, description: r.description ?? null }; -} - -/** - * The provenance value this seeder stamps on the row of a position a package - * holds — the value the permission-set and capability seeders stamp on their - * declared rows, and one the system-row write gate refuses an admin-door - * update or delete of (`SYSTEM_ROW_PROVENANCE`, security-plugin.ts). - */ -const PACKAGE_PROVENANCE = 'package'; - -/** - * The `managed_by` values the system-row write gate already treats as managed - * on `sys_position` — `SYSTEM_ROW_PROVENANCE` in security-plugin.ts, canonical - * and legacy spellings both. Keep the two in lockstep (see the same note in - * `normalize-managed-by.ts`). A row carrying one of these is never re-stamped: - * `platform`/`system` are the built-in seeder's, and a legacy `config` row is - * already guarded and is healed to `package` by the boot normalizer. - */ -const GATE_MANAGED_VALUES: ReadonlySet = new Set(['platform', 'package', 'system', 'config']); - -/** - * Does a code package hold this position name? - * - * Asked of the engine registry's artifact lookup (`getArtifactItem`), which - * answers only an item a code package registered — an environment definition - * saved through the metadata door carries no package there. It is the same - * lookup the metadata door refuses a save of the name from (`403 - * NOT_OVERRIDABLE`), so the data door now refuses what the metadata door - * refuses, and no more: a position the environment authored keeps an - * unmanaged row, editable as before. - * - * A registry without that lookup is asked the way the metadata door asks one - * (`lookupArtifactItem`, metadata-protocol): the definition must name a - * package, and not the `sys_metadata` rehydration sentinel. - */ -function packageHoldsName(registry: any, entry: { name: string; packageId?: string }): boolean { - if (typeof registry?.getArtifactItem === 'function') { - return registry.getArtifactItem('position', entry.name) !== undefined; - } - return typeof entry.packageId === 'string' && entry.packageId !== '' && entry.packageId !== 'sys_metadata'; -} - -/** - * [#22360] The provenance a pass writes on a declared position's row: the - * `package` stamp when a package holds the name and the row does not already - * carry a managed value, nothing otherwise. - * - * ## Why the row needs it - * - * Before this, a declared position's row carried the object default - * (`admin`), so the system-row write gate did not protect it: a data-door edit - * of its label answered `200`, and the next boot wrote the declaration's label - * back over it with no message. The metadata door refused the same edit - * (`403 NOT_OVERRIDABLE`). ADR-0131 D6: no door edits a managed definition; - * D3: managed items are read-only and clonable. With the stamp, the data door - * refuses a definition edit or a delete through the gate that already protects - * the six built-ins; a row-state-only patch passes its carve-out (d). - * - * ## Why this is not a #2909 T2 projection - * - * T2 locks that a re-seed never writes the record-authoritative columns from - * the declaration (bindings, `delegatable`, `active`, `is_default`). The stamp - * is not declaration content — a position declaration has no `managed_by` — - * and it overwrites no admin edit: `managed_by` is `readonly`, and the gate - * refuses an admin-door payload naming `platform`/`package`, so the `admin` - * on a declared row was only ever the object default. It records which door - * owns the name, the fact the metadata door already reads. - * - * ## The existing row of an upgraded deployment - * - * A declared row written before this carries `admin`. The pass corrects the - * stamp in place — `managed_by` and nothing else beyond the label and - * description refresh it always made — so a definition edit is refused there - * too, and the columns an administrator set before the upgrade stay as they - * are. - * ⚠️ That row is matched by NAME, as the label refresh always matched it: a - * Setup-created position whose name a package declares later is taken over by - * the package here, and from then on its definition is refused at the data - * door like any other package position's. - */ -function packageProvenanceStamp(packageHeld: boolean, existing?: any): { managed_by?: string } { - if (!packageHeld) return {}; - if (existing && GATE_MANAGED_VALUES.has(String(existing.managed_by ?? ''))) return {}; - return { managed_by: PACKAGE_PROVENANCE }; -} - -/** True when the stored row differs from what a re-seed would write (#10946). */ -function positionRecordDiffers(row: any, fields: { label: any; description: any }): boolean { - return (row?.label ?? null) !== (fields.label ?? null) - || (row?.description ?? null) !== (fields.description ?? null); -} - -export async function bootstrapDeclaredPositions( - ql: any, - metadataService: any, - options: SeedOptions = {}, -): Promise<{ seeded: number; updated: number; unchanged: number; unreadable: number }> { - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; - } - const declared = await readDeclaredPositions(ql, metadataService); - if (declared.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; - const positions = declared.map((d) => d.definition); - const packageHeld = new Map(declared.map((d) => [String(d.definition?.name), d.packageHeld])); - - // [#10946] ONE existence read for the whole declaration, before the loop. - // See `seed-name-lookup.ts` for why a read that cannot ANSWER must never be - // read as "none of them exist" — that conflation would re-create every - // position on every boot. - const organizationId = options.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_position', - positions.map((r) => r?.name), - options.logger, - organizationId, - ); - // Names for which a PRE-FIX organization-less row is still standing. This - // organization's own row is created regardless — the leftover is reported, - // never treated as "already seeded" (#10103). - const residue: string[] = []; - - let seeded = 0; - let updated = 0; - let unchanged = 0; - let unreadable = 0; - // [#22360] Existing rows whose provenance this pass corrected to `package` — - // counted inside `updated` as well, and named in the pass's info line. - let restamped = 0; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY declared position, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - for (const r of positions) { - if (!r?.name) continue; - const fields = positionRowFields(r); - // ⛔ Three outcomes, not two (#10946 / #3807): a read that FAILED is not - // "no such position". Inserting on it would re-create every position on - // every boot the database is briefly unreachable. - const lookup = await existingByName.get(String(r.name)); - if (lookup.status === 'unknown') { unreadable += 1; continue; } - // [#10103] `absent` can still carry a PRE-FIX organization-less row that is - // merely VISIBLE here through the driver's compatibility arm. It is not - // this organization's row, so the copy below is created either way — the - // leftover is only reported. Reading it as "already seeded" is the silent - // no-op the per-organization catalog exists to prevent. - if (lookup.status === 'absent' && lookup.organizationLessResidue) residue.push(String(r.name)); - const existing = lookup.status === 'present' ? lookup.row : undefined; - const heldByPackage = packageHeld.get(String(r.name)) === true; - if (existing?.id) { - // [#10946] Only write when the stored row actually differs. An - // unconditional UPDATE here cost two remote round trips per position on - // every boot to store the values already there. - // - // ⚠️ EQUALITY decides, not presence: a position whose stored label or - // description drifted from the declaration still gets its UPDATE. Only - // the display fields are compared because only the display fields are - // written — the record-authoritative columns (`active`, `is_default`, - // `delegatable`) are deliberately never touched by a re-seed (#2909 T2), - // so they can neither cause nor suppress one. - // - // [#22360] The one other column a pass writes is the provenance stamp, - // and only where it is missing (`packageProvenanceStamp`): an upgraded - // deployment's declared row, stamped `admin` by the object default. - const display = positionRecordDiffers(existing, fields) ? fields : {}; - const stamp = packageProvenanceStamp(heldByPackage, existing); - if (Object.keys(display).length === 0 && Object.keys(stamp).length === 0) { - unchanged += 1; - } else if (await tryUpdate(ql, 'sys_position', { id: existing.id, ...display, ...stamp }, organizationId, refusals)) { - updated += 1; - if (stamp.managed_by) restamped += 1; - } - } else { - const row = { - id: genId('position'), name: r.name, ...fields, active: true, is_default: false, - ...packageProvenanceStamp(heldByPackage), - }; - const created = await tryInsert(ql, 'sys_position', row, organizationId, refusals); - if (created) { - seeded += 1; - // The batched oracle is a snapshot taken before the loop; a name - // declared twice in one batch must resolve to the row we just made - // rather than attempting a second insert the unique index refuses. - existingByName.remember(String(r.name), row); - } - } - } - if (organizationId) { - // No `platformBucketNames`: nothing mints an organization-less `sys_position` - // row any more, so every leftover here really is pre-fix residue (#11532). - warnOrganizationLessRows(options.logger, 'sys_position', residue, organizationId); - } - // Before the counts are reported, so an operator reads WHY the count is zero - // in the same place they read the zero. - reportSeedWriteRefusals(options.logger, refusals, organizationId); - if (unreadable > 0) { - // Said once, with the count — see the sibling warn in - // `bootstrap-declared-permissions.ts`. - options.logger?.warn?.( - '[security] declared positions left untouched — their records could not be read', - { unreadable, total: positions.length, ...(organizationId ? { organization: organizationId } : {}) }, - ); - } - options.logger?.info?.('[security] declared positions seeded into sys_position', { - seeded, updated, unchanged, unreadable, total: positions.length, - // Present only when a row was corrected, so a routine boot's line is unchanged. - ...(restamped > 0 ? { restampedPackageProvenance: restamped } : {}), - ...(organizationId ? { organization: organizationId } : {}), - }); - return { seeded, updated, unchanged, unreadable }; -} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 83caa806b67..b6cab16d6bc 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -57,7 +57,6 @@ import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-che import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { @@ -79,12 +78,9 @@ import { type SuggestionListFilter, } from './suggested-audience-bindings.js'; import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; -import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { catalogIsPerOrganization, listSeedOrganizationIds, - resolveOwnOrganizationRow, - seedCtx, } from './organization-scope.js'; import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; @@ -1719,8 +1715,9 @@ export class SecurityPlugin implements Plugin { // engine registry by the registry's own seam, under this plugin's package // 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`). + // bootstrap and before any request reaches the metadata door. The + // declaration is their one home: the boot writes no `sys_position` row for + // them (ADR-0131 D2). const builtinRegistry = (ql as { registry?: unknown }).registry; if (registerBuiltinPositions(builtinRegistry, SECURITY_PLUGIN_ID) === 0) { ctx.logger.warn( @@ -4468,23 +4465,19 @@ export class SecurityPlugin implements Plugin { // conjunction of the two is the strict equality alone. Nothing about the // wall changes here, at either layer — the rows get an owner instead. // - // The four steps are named ONCE and driven from two places (the boot sweep + // The catalog step is named ONCE and driven from two places (the boot sweep // below, and the organization-creation hook further down) so a newly // created organization can never be seeded differently from one present at - // boot. See `organization-scope.ts` for the doctrine and the loud guard + // boot. [ADR-0131 D2/D3] Positions get no row from the boot: the six + // built-ins are declared by this plugin (`builtin-positions.ts`), a + // package's positions are registry items, and the `everyone` baseline is + // that anchor's declared `permissionSets` (`declareEveryoneBaseline`). See `organization-scope.ts` for the doctrine and the loud guard // that stands in place of a reap, and `rowMatchesDeclaration` for why the // boot sweep is O(changed declarations) rather than O(organizations x rows) // of blind writes. // // `organizationId` undefined is the `single`-posture pass and reproduces // the pre-existing organization-less behaviour exactly. - const seedCatalogPositions = async (organizationId?: string): Promise => { - try { - await bootstrapDeclaredPositions(ql, this.metadata, { logger: ctx.logger, organizationId }); - } catch (e) { - ctx.logger.warn('[security] declared-position seeding failed', { error: (e as Error).message, organization: organizationId }); - } - }; // [#11532] The names `bootstrapPlatformAdmin` seeds organization-less on // every boot. The per-organization pass sees those rows through the driver's // compatibility arm and used to report them as PRE-FIX leftovers with a @@ -4503,76 +4496,13 @@ export class SecurityPlugin implements Plugin { ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message, organization: organizationId }); } }; - const seedCatalogBuiltins = async (organizationId?: string): Promise => { - try { - await bootstrapBuiltinRoles(ql, { logger: ctx.logger, organizationId }); - } catch (e) { - ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message, organization: organizationId }); - } - }; - // [ADR-0090 D5] Bind the configured baseline set(s) to THIS organization's - // `everyone` anchor. Scoped for the same reason the anchors themselves are: - // once each organization holds its own `everyone` row, an unscoped - // `where: { name: 'everyone' }` would bind whichever copy the driver - // happened to order first — one organization's baseline landing in - // another's catalog. - const bindBaselineToEveryone = async (organizationId?: string): Promise => { - try { - // [#18535, ADR-0090 D5] The stack's declared `capabilities:` — the half - // of the anchor question the predicate cannot discover for itself. Read - // ONCE per pass, from the DECLARATIONS rather than from `sys_capability`: - // this binding runs before `bootstrapDeclaredCapabilities` seeds those - // rows (see `declared-capability-context.ts` for why that order is - // fixed), so the rows are empty here on a first boot. Unreadable or - // absent ⇒ `undefined` ⇒ the pre-#17811 verdict, which refuses. - const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); - for (const baselineName of this.baselinePermissionSets) { - const boot = this.bootstrapPermissionSets.find((p) => p.name === baselineName); - const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null; - if (offending) { - ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { - set: baselineName, offending, - }); - continue; - } - // Limit 5, not 1: a tenant-scoped read returns organization-less rows - // alongside this organization's own, and the two are not - // interchangeable — `resolveOwnOrganizationRow` is what tells them - // apart. - const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 5, context: seedCtx(organizationId) }); - const everyone: any = resolveOwnOrganizationRow(Array.isArray(everyoneRows) ? everyoneRows : [], organizationId).own; - const setRows = await ql.find('sys_permission_set', { where: { name: baselineName }, limit: 5, context: seedCtx(organizationId) }); - const set: any = resolveOwnOrganizationRow(Array.isArray(setRows) ? setRows : [], organizationId).own; - if (everyone?.id && set?.id) { - const existing = await ql.find('sys_position_permission_set', { - where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: seedCtx(organizationId), - }); - if (!(Array.isArray(existing) && existing[0])) { - await ql.insert('sys_position_permission_set', { - id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, - position_id: everyone.id, - permission_set_id: set.id, - }, { context: seedCtx(organizationId) }); - ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { - set: baselineName, ...(organizationId ? { organization: organizationId } : {}), - }); - } - } - } - } catch (e) { - ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message }); - } - }; /** * Every catalog step for ONE organization, in boot order. Used by the - * organization-creation hook; the boot sweep drives the same four steps + * organization-creation hook; the boot sweep drives the same step * interleaved with the organization-independent bootstrap work. */ const seedCatalogForOrganization = async (organizationId?: string): Promise => { - await seedCatalogPositions(organizationId); await seedCatalogPermissions(organizationId); - await seedCatalogBuiltins(organizationId); - await bindBaselineToEveryone(organizationId); }; /** * Which passes this boot performs: one per organization under a walled @@ -4615,16 +4545,8 @@ export class SecurityPlugin implements Plugin { // real answer: a later pass that returns none (walled posture, an // unreadable engine) must not erase the admin an earlier pass resolved. if (report?.adminUserId) this.claimTargetAdminUserId = report.adminUserId; - // Which organizations this boot seeds. Resolved ONCE per bootstrap run - // and reused by all four catalog steps, so a sweep costs one - // organization enumeration rather than four. + // Which organizations this boot seeds. Resolved ONCE per bootstrap run. const catalogPasses = await catalogSeedPasses(); - // [ADR-0057 D6 / #2077] Seed stack-declared positions into sys_position so they - // stop being decorative (position→permission-set resolution + recipients). - // One pass per organization under a walled posture. - for (const organizationId of catalogPasses) { - await seedCatalogPositions(organizationId); - } // [ADR-0086 D5] Seed stack-declared permission sets into // sys_permission_set with package provenance (managed_by:'package' + // package_id) — packages ship working default access for their own @@ -4635,9 +4557,6 @@ export class SecurityPlugin implements Plugin { await seedCatalogPermissions(organizationId); } - // [ADR-0090 D5] The baseline→`everyone` binding runs LATER — after - // `bootstrapBuiltinRoles` seeds the `everyone` anchor (the anchor must - // exist before it can be bound). See `bindFallbackToEveryone` below. // [ADR-0086 P2 — 块1] Register the publish-time materializer so a // permission set authored/edited through the PACKAGE door (saved as a // `permission` draft, then published) lands in sys_permission_set with @@ -4796,33 +4715,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); } - // [ADR-0068 D2] Seed the framework's reserved built-in identity positions - // (platform_admin / org_*) so the role catalog is self-describing. - for (const organizationId of catalogPasses) { - await seedCatalogBuiltins(organizationId); - } - // [ADR-0090 D5] Bind the configured baseline set(s) to the `everyone` - // audience anchor (idempotent). This makes the CLI/dev baseline - // (`fallbackPermissionSet` — the app's `isDefault` set) visible as an - // ordinary position binding: same table, same audit path, same explain - // surface as any admin-authored default grant. The binding is validated - // with the SAME high-privilege predicate the write gate enforces — a - // dangerous baseline is refused loudly, never seeded. MUST run after - // `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and before - // `syncAudienceBindingSuggestions` (so the app's own fallback set is - // already bound and never generates a redundant pending suggestion). - // - // [#7555] Every name in the COMPOSED baseline is bound, not just the - // declared one — the join table takes many rows per position, and the - // explain surface's whole job is to answer "what does a new member get" - // truthfully. Binding only the app's set while `member_default` also - // resolves at request time would make `security/explain` report a - // narrower default than the runtime actually applies. The refusal is - // PER SET: a high-privilege name is skipped loudly and the rest still - // bind (the D5/D9 anchor gate is untouched, and each set faces it). - for (const organizationId of catalogPasses) { - await bindBaselineToEveryone(organizationId); - } // [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface: // every declared `isDefault: true` set that is not already bound to // its anchor becomes a PENDING suggestion row awaiting admin From 93443eba56ce56417ee899cbd4639acf5a905171 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:44:03 +0000 Subject: [PATCH 2/7] test(plugin-security): retire the position-seeder cases; keep the accumulator, the exemption and the per-organization permission-set pins Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .../src/audience-anchors.test.ts | 61 +---- .../src/bootstrap-seed-round-trips.test.ts | 103 +------ .../objects/reserved-identity-names.test.ts | 31 ++- .../src/per-organization-catalog.test.ts | 74 ++--- .../src/seed-write-refusal.test.ts | 253 ++---------------- 5 files changed, 75 insertions(+), 447 deletions(-) diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index 9a4a8726a3c..0fad12fa269 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -1,67 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// ADR-0090 D5/D9 — audience anchors: everyone/guest seeding and the -// high-privilege binding gate. +// ADR-0090 D5/D9 — audience anchors: the high-privilege binding gate. The +// anchors themselves are declared position metadata (`builtin-positions.ts`); +// the boot writes no row for them (ADR-0131 D2). import { describe, it, expect } from 'vitest'; import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; -import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions'; -import { securityBuiltinPositions } from './builtin-positions'; import { describeHighPrivilegeBits } from './security-plugin'; -function makeQl() { - const tables: Record = { sys_position: [] }; - return { - tables, - async find(object: string, opts: any) { - const where = opts?.where ?? {}; - return (tables[object] ?? []).filter((r) => - Object.entries(where).every(([k, v]) => (r as any)[k] === v), - ); - }, - async insert(object: string, data: any) { - (tables[object] ??= []).push(data); - return data; - }, - async update(object: string, data: any) { - const t = tables[object] ?? []; - const i = t.findIndex((r) => r.id === data.id); - if (i >= 0) t[i] = { ...t[i], ...data }; - return t[i]; - }, - } as any; -} - -describe('audience anchors (ADR-0090 D5/D9)', () => { - it('seeds everyone and guest alongside the builtin identity names', async () => { - const ql = makeQl(); - const res = await bootstrapBuiltinRoles(ql); - const names = ql.tables.sys_position.map((r: any) => r.name); - expect(names).toEqual( - expect.arrayContaining(['platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']), - ); - expect(res.seeded).toBe(6); - // platform-managed, undeletable posture (A4 #2920 unified vocab; formerly 'system') - for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('platform'); - }); - - // [ADR-0131 C2 S2] The seed reads the declarations — the same list the - // plugin registers as position metadata — and has no list of its own. - it('seeds exactly the declared built-in positions, with their declared label and description', async () => { - const ql = makeQl(); - await bootstrapBuiltinRoles(ql); - const seeded = ql.tables.sys_position.map((r: any) => ({ name: r.name, label: r.label, description: r.description })); - expect(seeded).toEqual(securityBuiltinPositions.map(({ name, label, description }) => ({ name, label, description }))); - }); - - it('re-seed is idempotent (updates, no duplicates)', async () => { - const ql = makeQl(); - await bootstrapBuiltinRoles(ql); - const res2 = await bootstrapBuiltinRoles(ql); - expect(res2.seeded).toBe(0); - expect(ql.tables.sys_position.filter((r: any) => r.name === 'everyone')).toHaveLength(1); - }); -}); - describe('describeHighPrivilegeBits (anchor-binding predicate)', () => { it('flags VAMA, destructive bits and system permissions (the ADR-0090 D5 list)', () => { expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/); diff --git a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts index 8ff408fb832..2b77c4d6597 100644 --- a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts @@ -43,15 +43,11 @@ import { describe, it, expect } from 'vitest'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; -import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; /** * The engine registry as the security catalog read uses one — its list, its - * by-name read and its disabled-package question — over a fixed list. The - * declared-positions seeder reads through that catalog read (ADR-0131 C2 S2b), - * which also takes the metadata service: {@link NO_METADATA_POSITIONS} is one - * that declares nothing, so every position here comes from the registry. + * by-name read and its disabled-package question — over a fixed list. */ function catalogRegistry(type: string, items: () => any[]) { return { @@ -60,7 +56,6 @@ function catalogRegistry(type: string, items: () => any[]) { isPackageDisabled: () => false, }; } -const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; interface CountingQl { rows: any[]; @@ -178,17 +173,8 @@ const declaredSets = (n: number): DeclaredSet[] => _packageId: 'com.example.crm', })); -const declaredPositions = (n: number) => - Array.from({ length: n }, (_, i) => ({ - name: `pkg_pos_${i}`, - label: `Position ${i}`, - description: `desc ${i}`, - })); - const permissionQl = (declared: any[], behaviour = {}) => makeCountingQl('sys_permission_set', 'permission', declared, behaviour); -const positionQl = (declared: any[], behaviour = {}) => - makeCountingQl('sys_position', 'position', declared, behaviour); describe('#10946 — steady-state rebuild is O(1) round trips (permission sets)', () => { it('does not grow the rebuild round-trip count with the number of declared sets', async () => { @@ -228,33 +214,6 @@ describe('#10946 — steady-state rebuild is O(1) round trips (permission sets)' }); }); -describe('#10946 — steady-state rebuild is O(1) round trips (positions)', () => { - it('does not grow the rebuild round-trip count with the number of declared positions', async () => { - const measure = async (n: number) => { - const ql = positionQl(declaredPositions(n)); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - ql.reset(); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r.seeded).toBe(0); - expect(r.updated).toBe(0); - expect(r.unchanged).toBe(n); - return ql.roundTrips(); - }; - - const [n1, n5, n20, n40] = [await measure(1), await measure(5), await measure(20), await measure(40)]; - expect([n1, n5, n20, n40]).toEqual([1, 1, 1, 1]); - }); - - it('issues ONE batched `$in` existence read for the whole declaration', async () => { - const ql = positionQl(declaredPositions(12)); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - ql.reset(); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(ql.calls.find).toBe(1); - expect(ql.wheres[0]).toEqual({ name: { $in: declaredPositions(12).map((p) => p.name) } }); - }); -}); - /** * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write * would pass every count above while reconciling nothing at all. @@ -289,39 +248,6 @@ describe('#10946 — drift STILL reconciles', () => { expect(r.updated).toBe(1); expect(JSON.parse(ql.rows[1].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); }); - - it('a position row whose stored label/description differ still gets its UPDATE', async () => { - const ql = positionQl(declaredPositions(20)); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - - const upgraded = declaredPositions(20); - upgraded[3] = { ...upgraded[3], label: 'Renamed', description: 'new text' }; - (ql as any).registry = catalogRegistry('position', () => upgraded); - - ql.reset(); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r.updated).toBe(1); - expect(r.unchanged).toBe(19); - expect(ql.calls.update).toBe(1); - const row = ql.rows.find((x) => x.name === 'pkg_pos_3'); - expect(row.label).toBe('Renamed'); - expect(row.description).toBe('new text'); - }); - - it('a re-seed still never touches the record-authoritative columns (#2909 T2 kept)', async () => { - const ql = positionQl([{ name: 'contributor', label: 'Contributor v2', description: 'new' }]); - ql.rows.push({ - id: 'pos_1', name: 'contributor', label: 'Contributor', description: 'old', - active: false, is_default: true, delegatable: true, managed_by: 'package', - }); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - const row = ql.rows[0]; - expect(row.label).toBe('Contributor v2'); - expect(row.active).toBe(false); - expect(row.is_default).toBe(true); - expect(row.delegatable).toBe(true); - expect(row.managed_by).toBe('package'); - }); }); describe('#10946 — a genuinely NEW declaration is still created', () => { @@ -342,20 +268,6 @@ describe('#10946 — a genuinely NEW declaration is still created', () => { // one batched read + one insert — the other five cost nothing at all expect(ql.roundTrips()).toBe(2); }); - - it('the batched read does not turn "absent" into "present" (positions)', async () => { - const ql = positionQl(declaredPositions(5)); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - - const grown = [...declaredPositions(5), { name: 'pkg_pos_new', label: 'New', description: null }]; - (ql as any).registry = catalogRegistry('position', () => grown); - - ql.reset(); - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS); - expect(r.seeded).toBe(1); - expect(r.unchanged).toBe(5); - expect(ql.roundTrips()).toBe(2); - }); }); /** @@ -406,19 +318,6 @@ describe('#10946 — a read that CANNOT ANSWER is not the answer "none exist"', expect(ql.rows).toHaveLength(4); }); - it('a throwing read does NOT re-create rows that are already seeded (positions)', async () => { - const seeded = positionQl(declaredPositions(4)); - await bootstrapDeclaredPositions(seeded, NO_METADATA_POSITIONS); - - const broken = positionQl(declaredPositions(4), { findThrows: true }); - broken.rows.push(...seeded.rows.map((r) => ({ ...r }))); - const r = await bootstrapDeclaredPositions(broken, NO_METADATA_POSITIONS); - expect(r.seeded).toBe(0); - expect(r.unreadable).toBe(4); - expect(broken.calls.insert).toBe(0); - expect(broken.rows).toHaveLength(4); - }); - it('an EMPTY result set is still trusted as "none exist" — the first boot depends on it', async () => { const ql = permissionQl(declaredSets(3)); const r = await bootstrapDeclaredPermissions(ql, undefined); diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index 656344bbaf6..1274325e039 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -45,7 +45,6 @@ import { reservedIdentityNamesCelList, reservedIdentityNameMessage, } from './reserved-identity-names.js'; -import { bootstrapBuiltinRoles } from '../bootstrap-builtin-positions.js'; import { DelegatedAdminGate } from '../delegated-admin-gate.js'; const engines: ObjectQL[] = []; @@ -208,19 +207,27 @@ describe('#15972 sys_position — the object layer refuses the reserved names on expect(err.code).toBe(VALIDATION_FAILED_CODE); }); - it("the PLATFORM's own catalog seed is unaffected — all four names still seed", async () => { + it("a PLATFORM-provenance row spelling a reserved name still writes — the exemption's one holder", async () => { const engine = await boot(); - // The exemption exists for exactly this writer. If it regressed, the seed - // would report zero rows and the built-in role catalog would vanish — so - // this leg is what keeps the guard from being a boot-breaking change. - const result = await bootstrapBuiltinRoles(engine as any); - expect(result.seeded).toBe(BUILTIN_IDENTITY_NAMES.length + 2); // + everyone / guest + // [ADR-0131 D2] The built-in seeder that wrote these rows is retired: the + // six built-ins are declared metadata and the boot writes no row. Rows it + // wrote stay on existing databases (C7 owns their removal), stamped + // `managed_by: 'platform'` under an `isSystem` context, and the exemption + // keeps them writable — an insert of that shape, and the UPDATE leg over a + // row that already spells a reserved name, must not start refusing. + for (const name of BUILTIN_IDENTITY_NAMES) { + await (engine as any).insert( + 'sys_position', + { id: `pos_${name}`, name, label: name, managed_by: 'platform', active: true, is_default: false }, + { context: { isSystem: true } }, + ); + } const rows = await (engine as any).find('sys_position', { where: {}, context: { isSystem: true } }); - const seeded = rows.map((r: any) => r.name); - for (const name of BUILTIN_IDENTITY_NAMES) expect(seeded).toContain(name); - // Re-running is the idempotent upsert path, i.e. the UPDATE leg over rows - // that already spell reserved names. It must not start refusing itself. - await expect(bootstrapBuiltinRoles(engine as any)).resolves.toBeTruthy(); + const written = rows.map((r: any) => r.name); + for (const name of BUILTIN_IDENTITY_NAMES) expect(written).toContain(name); + await expect((engine as any).update( + 'sys_position', { id: 'pos_org_admin', label: 'Organization Admin' }, { context: { isSystem: true } }, + )).resolves.toBeTruthy(); }); }); 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 7bdff2281e9..f03e9a2746e 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -42,8 +42,6 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { resolveUserAuthzGrants } from '@objectstack/core'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; -import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; @@ -65,10 +63,9 @@ afterEach(async () => { }); /** - * One declared position and one packaged permission set to seed. The by-name - * read and the disabled-package question are the members the security catalog - * read adds to `listItems` — the declared-positions seeder reads through it - * (ADR-0131 C2 S2b), with {@link NO_METADATA_POSITIONS} as its metadata service. + * One declared position and one packaged permission set. [ADR-0131 D2] The + * position is declared and gets no row: no boot seeder writes `sys_position` + * any more, which case 1 holds as a census beside the permission set's rows. */ const STUB_REGISTRY = { listItems: (type: string) => { @@ -79,7 +76,6 @@ const STUB_REGISTRY = { getItem: (type: string, name: string) => STUB_REGISTRY.listItems(type).find((item) => item.name === name), isPackageDisabled: () => false, }; -const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; /** A logger that records what the seeders said, so a LOUD guard can be asserted. */ function recordingLogger() { @@ -137,7 +133,7 @@ async function boot(): Promise { * the PRODUCER's own dispatch predicate (#4550 / #5480 / #6277) rather than a * hand-mirrored guard, because a seam that merely forwards is exactly the shape * that reads as "not a double" and then admits a call the real engine would - * reject. `delete` is not declared at all — none of the three seeders deletes, + * reject. `delete` is not declared at all — the seeder does not delete, * and a verb no caller exercises would be a contract this seam does not have to * make. */ @@ -159,12 +155,14 @@ async function stored(engine: ObjectQL, table: string): Promise { return driver.knex(table).select('*'); } -/** Run the three catalog seeders for one organization (walled), or none (single). */ +/** + * Run the catalog seeder for one organization (walled), or none (single). + * [ADR-0131 D2] The two position seeders are retired; the permission-set + * seeder is the one left here. + */ async function seedCatalog(engine: ObjectQL, logger: any, organizationId?: string): Promise { const ql = withRegistry(engine); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId }); await bootstrapDeclaredPermissions(ql, null, { logger, organizationId }); - await bootstrapBuiltinRoles(ql, { logger, organizationId }); } const orgOf = (r: any): string | null => (r.organization_id ?? null); @@ -178,17 +176,16 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { const positions = await stored(engine, 'sys_position'); const sets = await stored(engine, 'sys_permission_set'); + // [ADR-0131 D2] No position row at all — the declared `sales_manager` and + // the six built-ins are registry items, written to no table. + expect(positions).toEqual([]); + // The inversion of the shipped behaviour: no row belongs to nobody. - expect(positions.filter((r) => orgOf(r) === null)).toEqual([]); expect(sets.filter((r) => orgOf(r) === null)).toEqual([]); // Stated per organization so a failure names the tenant that lost its // catalog rather than printing a set diff. for (const org of ORGS) { - const names = positions.filter((r) => orgOf(r) === org).map((r) => r.name).sort(); - expect(names).toEqual([ - 'everyone', 'guest', 'org_admin', 'org_member', 'org_owner', 'platform_admin', 'sales_manager', - ]); expect(sets.filter((r) => orgOf(r) === org).map((r) => r.name)).toEqual(['sales_readonly']); } }); @@ -201,14 +198,12 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { // The AST plugin-security's Layer 0 AND-composes for `isolated`: a strict // `organization_id = `, over which the driver adds its own // predicate. This is the exact read that returned nothing. - const walled = await (engine as any).find('sys_position', { + const walled = await (engine as any).find('sys_permission_set', { where: { organization_id: ORG_JIA }, context: { isSystem: true, tenantId: ORG_JIA }, }); const names = (walled as any[]).map((r) => r.name).sort(); - expect(names).toContain('everyone'); - expect(names).toContain('sales_manager'); - expect(names).toHaveLength(7); + expect(names).toEqual(['sales_readonly']); // And the wall still walls: org_yi's copies are not in org_jia's read. for (const row of walled as any[]) expect(orgOf(row)).toBe(ORG_JIA); @@ -222,10 +217,10 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { // shipped behaviour, writing the rows every deployment that booted the old // code carries. await seedCatalog(engine, logger, undefined); - const before = await stored(engine, 'sys_position'); + const before = await stored(engine, 'sys_permission_set'); expect(before.every((r) => orgOf(r) === null)).toBe(true); - const everyoneBefore = before.filter((r) => r.name === 'everyone'); - expect(everyoneBefore).toHaveLength(1); + const setBefore = before.filter((r) => r.name === 'sales_readonly'); + expect(setBefore).toHaveLength(1); warns.length = 0; for (const org of ORGS) await seedCatalog(engine, logger, org); @@ -234,15 +229,15 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { // the organization-less row through the driver's compatibility arm, reads // the name as already represented, takes the update branch and creates // nothing — leaving the deployment as broken as before while reporting - // success. Each organization now has its OWN `everyone`. - const after = await stored(engine, 'sys_position'); - const everyoneAfter = after.filter((r) => r.name === 'everyone'); + // success. Each organization now has its OWN `sales_readonly`. + const after = await stored(engine, 'sys_permission_set'); + const setAfter = after.filter((r) => r.name === 'sales_readonly'); // Sorted with `null` spelled explicitly rather than left to the default // comparator, which stringifies and orders `null` first. - expect(everyoneAfter.map(orgOf).sort((a, b) => `${a}`.localeCompare(`${b}`))) + expect(setAfter.map(orgOf).sort((a, b) => `${a}`.localeCompare(`${b}`))) .toEqual([ORG_JIA, ORG_YI, null].sort((a, b) => `${a}`.localeCompare(`${b}`))); - expect(everyoneAfter.filter((r) => orgOf(r) === ORG_JIA)).toHaveLength(1); - expect(everyoneAfter.filter((r) => orgOf(r) === ORG_YI)).toHaveLength(1); + expect(setAfter.filter((r) => orgOf(r) === ORG_JIA)).toHaveLength(1); + expect(setAfter.filter((r) => orgOf(r) === ORG_YI)).toHaveLength(1); // (b) LOUD. Named rows, named remedy — this is what stands in place of the // reap, so a warning that merely counted would not discharge it. @@ -252,8 +247,7 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { const forOrg = guard.filter((w) => w.meta?.organization === org); expect(forOrg.length).toBeGreaterThan(0); const names = forOrg.flatMap((w) => w.meta?.names ?? []); - expect(names).toContain('everyone'); - expect(names).toContain('sales_manager'); + expect(names).toContain('sales_readonly'); } // The remedy is spelled out, and the reason nothing is deleted with it. const text = guard.map((w) => w.message).join(' '); @@ -263,7 +257,7 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { // (c) and nothing was reaped — the organization-less row that grants point // at is still there, which is why the guard is a warning and not a delete. - expect(everyoneAfter.filter((r) => orgOf(r) === null)).toHaveLength(1); + expect(setAfter.filter((r) => orgOf(r) === null)).toHaveLength(1); }); it('4. `single`-posture carve-out: exactly ONE organization-less pass, no copies, and NO warning', async () => { @@ -273,9 +267,9 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { await seedCatalog(engine, logger, undefined); await seedCatalog(engine, logger, undefined); // idempotent re-run, as at boot - const positions = await stored(engine, 'sys_position'); - expect(positions.every((r) => orgOf(r) === null)).toBe(true); - expect(positions.filter((r) => r.name === 'everyone')).toHaveLength(1); + const sets = await stored(engine, 'sys_permission_set'); + expect(sets.every((r) => orgOf(r) === null)).toBe(true); + expect(sets.filter((r) => r.name === 'sales_readonly')).toHaveLength(1); // An organization-less row is the CORRECT shape here, so the guard that // calls it invalid state must stay silent. expect(warns.filter((w) => w.message.includes('pre-fix organization-less'))).toEqual([]); @@ -302,8 +296,14 @@ describe('[#10103] per-organization RBAC catalog materialization', () => { 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); + // [ADR-0131 D2] No boot seeder writes `everyone` rows any more; an existing + // database still holds the per-organization copies the retired seeder + // wrote, and that is what an admin's junction row points at. + for (const org of ORGS) { + await (engine as any).insert('sys_position', + { id: `pos_everyone_${org}`, name: 'everyone', label: 'Everyone', managed_by: 'platform' }, + { context: { isSystem: true, tenantId: org } }); + } const positions = await stored(engine, 'sys_position'); const everyoneOf = (org: string) => positions.find((r) => r.name === 'everyone' && orgOf(r) === org); diff --git a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts index 7436e02502c..47338c864b6 100644 --- a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts @@ -36,6 +36,11 @@ * is a confident wrong answer; * 4. the existing green path still reports its counts unchanged. * + * [ADR-0131 D2] The cases that drove these through the two position seeders + * are gone with the seeders. What is pinned here is the shared accumulator + * (`write-refusals.ts`) the surviving writers report through; each surviving + * writer's own suite pins its call into it. + * * The classification is the SHIPPED predicate's (`isUniqueViolationError` in * `@objectstack/types`), never a local `23505` / `ER_DUP_ENTRY` regex — the * four-mutually-different-answers defect that module was written to retire. The @@ -48,9 +53,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import ts from 'typescript'; import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; -import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { createSeedWriteRefusals, reportSeedWriteRefusals, @@ -142,137 +144,12 @@ function makeWarnOnlyLogger() { }; } -/** A metadata service that declares no position: every position here comes from the registry. */ -const NO_METADATA_POSITIONS = { get: async () => undefined, list: async () => [] }; - -/** - * `sys_position` double whose INSERT is vetoed the way a legacy platform-wide - * unique index vetoes it: the row never lands and the driver throws. - * - * Modelled on the double in `bootstrap-declared-positions.test.ts` — `$in` is - * supported because the seeders hoist one batched existence read out of their - * loop, and a double that answered `[]` to `$in` would report "nothing is - * seeded" and make every re-seed look like a first boot. - */ -function makeQl( - declared: any[] = [], - opts: { insertThrows?: () => unknown; updateThrows?: () => unknown } = {}, -) { - const rows: any[] = []; - return { - rows, - // The members the security catalog read takes from the engine registry — - // the declared-positions seeder reads through it (ADR-0131 C2 S2b), with - // `NO_METADATA_POSITIONS` as the metadata service. - registry: { - listItems: (type: string) => (type === 'position' ? [...declared] : []), - getItem: (type: string, name: string) => (type === 'position' ? declared.find((d) => d?.name === name) : undefined), - isPackageDisabled: () => false, - }, - async find(object: string, q: any) { - if (object !== 'sys_position') return []; - const where = q?.where ?? {}; - const matched = rows.filter((r) => - Object.entries(where).every(([k, v]) => { - // Refuse what this double does not implement, rather than reading a - // combinator as a field name and silently matching nothing. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return r[k] === v; - }), - ); - // The caller's bound is applied AFTER the filter, by presence: a double - // that silently drops `limit` answers a different question than the real - // engine and hides a paging defect from every test that uses it. - return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; - }, - async insert(object: string, data: any) { - if (opts.insertThrows) throw opts.insertThrows(); - if (object !== 'sys_position') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - // Pinned to ObjectQL.update's own dispatch predicate: a fake looser than - // the real engine is how a dead call shape ships with its suite green. - async update(object: string, data: any, options?: any) { - if (opts.updateThrows) throw opts.updateThrows(); - const dispatch = assertEngineUpdateDispatch(data, options); - if (object !== 'sys_position') return dispatch.kind === 'by-id' ? null : 0; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r) => r.id === dispatch.id) - : rows; - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; -} - -const THREE_POSITIONS = [ - { name: 'contributor', label: 'Contributor', description: 'Does work' }, - { name: 'reviewer', label: 'Reviewer', description: 'Reviews work' }, - { name: 'approver', label: 'Approver', description: 'Approves work' }, -]; - -/** - * The refusal lines this change adds, as opposed to any pre-existing ones — - * gathered across BOTH sinks, because the two classes deliberately land on - * different channels. A test that watched only `warn` would read the - * unique-violation line's promotion to `error` as its disappearance. - */ -function refusalLines(...sinks: WarnLine[][]): WarnLine[] { - return sinks.flat().filter((w) => typeof w.meta.refused === 'number'); -} - /* ------------------------------------------------------------------------- * - * 1 — a refused INSERT is LOUD, and the pass still returns + * 1 — the report never echoes the driver message * ------------------------------------------------------------------------- */ -describe('a unique-violation refusal during catalog seeding is boot-visible', () => { - it('warns, and does NOT throw, when every declared position is vetoed', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl(THREE_POSITIONS, { insertThrows: mysqlDuplicateEntry }); - - // ⭐ Resolves rather than rejects. A rethrow would turn a silent - // degradation into a boot failure on every deployment carrying the legacy - // index — a behaviour change this repair deliberately does not make. - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - // The seed really did land nothing — the defect's precondition holds. - expect(r.seeded).toBe(0); - expect(ql.rows).toHaveLength(0); - - // ⭐ On the DURABILITY channel, not the functional one: the boot goes on to - // look healthy while a catalog it reported as seeded did not land. - expect(errors).toHaveLength(1); - expect(refusalLines(warns)).toHaveLength(0); - const [line] = errors; - // The cause slot stays empty and the detail travels in meta — a summary of - // N refusals has no single cause, and putting meta in the cause slot is - // where a `Logger` neither reads nor serializes it. - expect(line.cause).toBeUndefined(); - expect(line.meta).toMatchObject({ - object: 'sys_position', - organization: 'org_1', - refused: 3, - class: 'unique-violation', - }); - // The value-free code channel is what identifies the dialect's refusal. - expect(line.meta.driverCodes).toEqual(['1062', 'ER_DUP_ENTRY']); - // The operator is told it is a deployment-schema defect and given the remedy. - expect(line.message).toContain('REFUSED BY A UNIQUE CONSTRAINT'); - expect(line.message).toContain('os migrate plan'); - expect(line.message).toContain('os migrate apply'); - // ⭐ And it points at where the COLLIDING INDEX is named — the engine's own - // redacted entries — rather than reprinting driver text from here. - expect(line.message).toContain('COLLIDING INDEX'); - expect(line.message).toContain('Insert operation failed'); - }); - - it('never echoes the driver message or the bound statement into the warning', async () => { +describe('a refusal report carries no bound statement', () => { + it('never echoes the driver message or the bound statement into the line', () => { const { logger, warns, errors } = makeLogger(); // A driver message shaped the way knex builds one: the fully bound // statement, values inlined, then the database's own diagnostic. @@ -284,78 +161,20 @@ describe('a unique-violation refusal during catalog seeding is boot-visible', () ), { code: 'ER_DUP_ENTRY', errno: 1062 }, ); - const ql = makeQl(THREE_POSITIONS, { insertThrows: () => leaky }); + const refusals = createSeedWriteRefusals(); + refusals.record('sys_position', leaky); - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); + reportSeedWriteRefusals(logger, refusals, 'org_1'); - const serialized = JSON.stringify(refusalLines(warns, errors)); + const serialized = JSON.stringify([...warns, ...errors]); + expect(errors).toHaveLength(1); // [#8682] This is a server LOG, which is exactly the boundary the bound- - // statement redaction governs. The seeder reads the value-free `code` / - // `errno` channel and never the message channel, so a canary in the + // statement redaction governs. The accumulator reads the value-free `code` + // / `errno` channel and never the message channel, so a canary in the // statement cannot reach the log through this line. expect(serialized).not.toContain('SENSITIVE-CANARY-9f3a2b'); expect(serialized).not.toContain('insert into'); }); - - it('reports a refused UPDATE on the same channel as a refused INSERT', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl( - [{ name: 'contributor', label: 'Contributor v2', description: 'new text' }], - { updateThrows: postgresUniqueViolation }, - ); - ql.rows.push({ - id: 'position_existing', name: 'contributor', label: 'Contributor', description: 'old', - organization_id: 'org_1', - }); - - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - expect(r.updated).toBe(0); - const refusals = refusalLines(warns, errors); - expect(refusals).toHaveLength(1); - expect(refusals[0].meta).toMatchObject({ refused: 1, class: 'unique-violation' }); - expect(refusals[0].meta.driverCodes).toEqual(['23505']); - expect(errors).toHaveLength(1); - }); -}); - -/* ------------------------------------------------------------------------- * - * 2 — AGGREGATED: N refusals produce ONE line - * ------------------------------------------------------------------------- */ - -describe('the refusal warning is aggregated, not one line per refused row', () => { - it('prints ONE line for 40 refusals in a single pass', async () => { - const { logger, warns, errors } = makeLogger(); - const many = Array.from({ length: 40 }, (_, i) => ({ - name: `position_${i}`, label: `Position ${i}`, description: 'x', - })); - const ql = makeQl(many, { insertThrows: mysqlDuplicateEntry }); - - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - expect(r.seeded).toBe(0); - const refusals = refusalLines(warns, errors); - // ⭐ ONE actionable line per object per class per pass. The alternative - // floods the boot log and buries the one sentence naming the remedy — - // the same reason `warnOrganizationLessRows` aggregates. - expect(refusals).toHaveLength(1); - // …and it still carries the true count, so the line is a report and not - // merely a sample. - expect(refusals[0].meta.refused).toBe(40); - }); - - it('aggregates the built-in position pass the same way', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl([], { insertThrows: mysqlDuplicateEntry }); - - const r = await bootstrapBuiltinRoles(ql, { logger, organizationId: 'org_1' }); - - expect(r.seeded).toBe(0); - const refusals = refusalLines(warns, errors); - expect(refusals).toHaveLength(1); - expect(refusals[0].meta.refused).toBeGreaterThan(1); - expect(refusals[0].meta.class).toBe('unique-violation'); - }); }); /* ------------------------------------------------------------------------- * @@ -363,25 +182,6 @@ describe('the refusal warning is aggregated, not one line per refused row', () = * ------------------------------------------------------------------------- */ describe('a refusal that is not a unique violation keeps its own class', () => { - it('does not send the operator to `os migrate` for a connection failure', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl(THREE_POSITIONS, { insertThrows: connectionFailure }); - - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - // ⭐ FUNCTIONAL channel, deliberately. Escalating a retrying outage to - // `error` is the over-application that trains everyone to skim `error`, - // which is what made the founding incident's `warn` unreadable. - expect(errors).toHaveLength(0); - const refusals = refusalLines(warns); - expect(refusals).toHaveLength(1); - expect(refusals[0].meta).toMatchObject({ refused: 3, class: 'other' }); - expect(refusals[0].message).toContain('NOT a unique-constraint violation'); - // ⭐ The migrate remedy belongs to the OTHER class and must not appear - // here: no migration can repair a database that is unreachable, and a - // confident wrong remedy is worse than none. - expect(refusals[0].message).not.toContain('os migrate plan'); - }); it('separates the two classes into two lines when a pass sees both', () => { const { logger, warns, errors } = makeLogger(); @@ -526,16 +326,6 @@ describe('the two classes take different log levels (AGENTS.md degradation rule) // slot the reduced sink does not have. expect(warns[0].meta.refused).toBe(1); }); - - it('reaches the durability channel through the real seeder, not just the helper', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl(THREE_POSITIONS, { insertThrows: postgresUniqueViolation }); - - await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - expect(errors).toHaveLength(1); - expect(refusalLines(warns)).toHaveLength(0); - }); }); /* ------------------------------------------------------------------------- * @@ -543,19 +333,6 @@ describe('the two classes take different log levels (AGENTS.md degradation rule) * ------------------------------------------------------------------------- */ describe('a pass that is not refused reports exactly what it did before', () => { - it('says nothing about refusals and reports its counts unchanged', async () => { - const { logger, warns, errors } = makeLogger(); - const ql = makeQl(THREE_POSITIONS); - - const r = await bootstrapDeclaredPositions(ql, NO_METADATA_POSITIONS, { logger, organizationId: 'org_1' }); - - expect(r).toMatchObject({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); - expect(ql.rows).toHaveLength(3); - // ⭐ Silence on the healthy path is load-bearing: a warning printed on - // every boot of every deployment is the false-alarm class that trains - // operators to skim exactly this channel. - expect(refusalLines(warns, errors)).toHaveLength(0); - }); it('emits nothing at all when a pass recorded no refusal', () => { const { logger, warns } = makeLogger(); From 5dbe7775f44115c7eafe8703dda3f3caf0aee7f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:50:56 +0000 Subject: [PATCH 3/7] test(plugin-security): pin the zero-row boot census, an existing database's unchanged rows and grants, and the everyone baseline as a declaration Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .../src/builtin-positions.boot.test.ts | 374 ++++++------------ .../src/catalog-reference-report.test.ts | 13 +- .../src/security-plugin.test.ts | 55 ++- 3 files changed, 157 insertions(+), 285 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 5621c734116..76bd7e7bac0 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -1,22 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D2, C2 stages S2 and S2b] The six built-in positions as declared - * metadata, and the declared-positions seeder reading through the security - * catalog read, read off a REAL boot of this plugin: what the catalog read - * lists, what the boot writes into `sys_position`, what every principal is - * granted, and which position writes are still refused. + * [ADR-0131 D2, C2 stages S2 and S2b, C3 stage 6a] The six built-in positions + * as declared metadata, read off a REAL boot of this plugin: what the catalog + * read lists, what the boot writes into `sys_position`, what every principal + * is granted, and which position writes are still refused. * - * The stage moves where the six come from — one declaration list, registered - * with the engine registry and read by the built-in seeder — and keeps the - * declared-positions seeder off them. No resolver reads a definition yet, so - * the only ways a grant could move are a catalog row that is no longer seeded, - * seeded differently, or seeded twice: above all `everyone`, which carries - * every authenticated principal's baseline through its binding. So this boot - * is the plugin's own — `init`, `start`, then its `kernel:ready` handlers in - * registration order (catalog seeding and the baseline binding to `everyone` - * included) — over a real `ObjectQL` engine on SQLite, with a stack-declared - * position in the metadata service beside the six. + * The six are one declaration list, registered with the engine registry, and + * `everyone`'s baseline is that anchor's declared `permissionSets`. Since + * stage 6a no boot step writes a `sys_position` row: the built-in seeder, the + * declared-positions seeder and the baseline junction writer are retired. So + * this boot is the plugin's own — `init`, `start`, then its `kernel:ready` + * handlers in registration order — over a real `ObjectQL` engine on SQLite, + * with a stack-declared position in the metadata service beside the six. * * ## The scenarios * @@ -29,31 +25,28 @@ * - `walled` — one pass per organization at boot, then one more organization * created after it (the organization-creation path); * - * each booted three ways: + * each booted four ways: * - * - as it is; + * - as it is (a fresh database); * - with `a door-authored position in the registry` — one position registered * the way a metadata author's saved definition is hydrated (no package); * - with `a stored definition under a built-in name` — `org_admin` and * `everyone` saved the same way before the six were declared, so each - * shadows its declaration at read (the registry's bare slot answers first). + * shadows its declaration at read (the registry's bare slot answers first); + * - as `an existing database` — holding the rows the retired seeders wrote on + * an earlier boot ({@link LEGACY_ROWS}). * * ## The goldens * - * The row census, the `sys_position` write ledger and the grant envelopes of - * the three postures as they are were recorded from the tree BEFORE the - * declarations existed (objectstack `51290bca2c`, this file run against that - * tree's sources; S2's PR record carries the run) and are unchanged since. + * The census and the write ledger: no boot writes a `sys_position` row in any + * scenario, and an existing database keeps exactly the rows it had. The census + * instrument is proven able to see a row by a positive control (one + * organization-authored create, read back as exactly one row). * - * S2b changes the census and the ledger in one place, the door-authored - * scenarios: the declared-positions seeder took the registry ALONE whenever it - * held a position besides the six, so the door-authored position silenced the - * stack's `field_rep` in every pass — and in every organization created later. - * It now reads both sources, so `field_rep` is seeded beside it. The - * door-authored position's own rows are what the registry-only read wrote - * (a separate pin, green on both sides of S2b), the stored definitions under - * a built-in name change no row and no write, and no principal's grants move - * in any scenario: none of them holds a position S2b newly seeds. + * The grant envelopes were recorded from the tree BEFORE the declarations + * existed (objectstack `51290bca2c`; S2's PR record carries the run), with the + * one recorded move stage 1 made ({@link GRANT_GOLDEN}). Stage 6a moves none: + * a fresh database and an existing one grant every principal the same. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -112,7 +105,12 @@ const APP_DEFAULT = { type PostureName = 'single' | 'single + organization' | 'walled'; const DOOR = 'a door-authored position in the registry'; const SHADOW = 'a stored definition under a built-in name'; -type ScenarioName = PostureName | `${PostureName}, ${typeof DOOR}` | `${PostureName}, ${typeof SHADOW}`; +const EXISTING = 'an existing database'; +type ScenarioName = + | PostureName + | `${PostureName}, ${typeof DOOR}` + | `${PostureName}, ${typeof SHADOW}` + | `${PostureName}, ${typeof EXISTING}`; interface Scenario { readonly posture: 'single' | 'isolated'; /** Organizations (with the memberships) present before `kernel:ready`. */ @@ -123,17 +121,21 @@ interface Scenario { readonly authoredPosition: boolean; /** Package-less definitions under built-in names in the registry before the boot ({@link SHADOWING_DEFINITIONS}). */ readonly shadowedBuiltins: boolean; + /** The rows the retired seeders wrote on an earlier boot, present before this one ({@link LEGACY_ROWS}). */ + readonly existingRows: boolean; } -const POSTURES: Record> = { +type Variant = 'authoredPosition' | 'shadowedBuiltins' | 'existingRows'; +const POSTURES: Record> = { single: { posture: 'single', organizationAtBoot: false, lateOrganization: false }, 'single + organization': { posture: 'single', organizationAtBoot: true, lateOrganization: false }, walled: { posture: 'isolated', organizationAtBoot: true, lateOrganization: true }, }; const SCENARIOS = Object.fromEntries( (Object.keys(POSTURES) as PostureName[]).flatMap((name) => [ - [name, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: false }], - [`${name}, ${DOOR}`, { ...POSTURES[name], authoredPosition: true, shadowedBuiltins: false }], - [`${name}, ${SHADOW}`, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: true }], + [name, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: false, existingRows: false }], + [`${name}, ${DOOR}`, { ...POSTURES[name], authoredPosition: true, shadowedBuiltins: false, existingRows: false }], + [`${name}, ${SHADOW}`, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: true, existingRows: false }], + [`${name}, ${EXISTING}`, { ...POSTURES[name], authoredPosition: false, shadowedBuiltins: false, existingRows: true }], ]), ) as Record; @@ -183,6 +185,14 @@ interface Booted { census: string[]; } +/** Every `sys_position` row, one string per row, past every scope. */ +async function positionCensus(engine: ObjectQL): Promise { + const rows = (await engine.find('sys_position', { where: {}, limit: 5000, context: SYS })) as any[]; + return rows + .map((r) => [r.name, r.organization_id ?? '-', r.managed_by, r.active, r.is_default, r.label, r.description ?? '-'].join(' | ')) + .sort(); +} + async function boot(scenario: Scenario): Promise { delete process.env[POSTURE_ENV]; delete process.env[OWNER_ENV]; @@ -309,19 +319,25 @@ async function boot(scenario: Scenario): Promise { ], { context: { isSystem: true, tenantId: ORG } } as any); } - // The plugin's own boot: seeders, the baseline binding to `everyone`, the - // organization-admin backfill — in registration order, as the kernel fires them. + if (scenario.existingRows) { + // What an earlier boot's retired seeders left: written through the + // unwrapped engine, so the ledger records only what THIS boot writes. + const organizationId = scenario.posture === 'isolated' ? ORG : undefined; + const context = organizationId ? { isSystem: true, tenantId: organizationId } : SYS; + for (const row of LEGACY_ROWS) { + await insert('sys_position', { id: `pos_${row.name}`, ...row, active: true, is_default: false }, { context } as any); + } + } + + // The plugin's own boot: the bootstrap, the `everyone` baseline declaration, + // the organization-admin backfill — in registration order, as the kernel fires them. for (const handler of hooks.get('kernel:ready') ?? []) await handler(); if (scenario.lateOrganization) { await engine.insert('sys_organization', { id: LATE_ORG, name: 'Late Org', slug: 'late' }, { context: SYS } as any); } recording = false; - const rows = (await engine.find('sys_position', { where: {}, limit: 5000, context: SYS })) as any[]; - const census = rows - .map((r) => [r.name, r.organization_id ?? '-', r.managed_by, r.active, r.is_default, r.label, r.description ?? '-'].join(' | ')) - .sort(); - return { engine, metadata, ledger, census }; + return { engine, metadata, ledger, census: await positionCensus(engine) }; } /** The four principals' resolved grants — needs the organization and its memberships. */ @@ -387,7 +403,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL): Promise> { +async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; @@ -412,10 +428,15 @@ async function tenantPositionWrites(engine: ObjectQL): Promise { - const { census, ledger } = booted.get(name)!; - expect({ census, ledger }).toEqual(CATALOG_GOLDEN[name]); + // P2.2 — the rows, and who wrote them: none, in any pass (stage 6a). An + // existing database keeps exactly the rows it had. + it('writes no sys_position row, and leaves an existing database\u2019s rows as they were', async () => { + const { engine, census, ledger } = booted.get(name)!; + expect({ census, ledger }).toEqual({ census: scenario.existingRows ? legacyCensus(scenario) : [], ledger: [] }); + + // Positive control: the census sees a row — one organization-authored + // create reads back as exactly one more. Removed again, so the cases + // below read the boot's state. + const context = { isSystem: true, tenantId: ORG }; + await engine.insert('sys_position', { id: 'pos_census_control', name: 'zz_census_control', label: 'Control' }, { context } as any); + const after = await positionCensus(engine); + expect(after.filter((line) => !census.includes(line))).toEqual([ + `zz_census_control | ${ORG} | admin | true | false | Control | -`, + ]); + await engine.delete('sys_position', { where: { id: 'pos_census_control' }, context } as any); + expect(await positionCensus(engine)).toEqual(census); }); - if (scenario.authoredPosition) { - // S2b moves which positions seed beside the door-authored one, never - // that one's own rows: the same row, written once per pass, with what the - // registry-only read wrote. - it('writes the door-authored position’s rows as the registry-only read wrote them', () => { - const { census, ledger } = booted.get(name)!; - expect({ - census: census.filter((line) => line.startsWith('door_authored ')), - ledger: ledger.filter((line) => line.includes(' door_authored@')), - }).toEqual(DOOR_AUTHORED_ROWS[scenario.lateOrganization ? 'walled' : 'single']); - }); - } - if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; @@ -485,8 +505,10 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { }); it('still refuses a tenant a dangling position name, and deleting or relabelling a built-in row', async () => { + // A fresh database holds no built-in row to delete or relabel; the two + // refusals are read where the rows are, on an existing database. const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine)).toEqual(REFUSAL_GOLDEN); + expect(await tenantPositionWrites(engine, scenario.existingRows)).toEqual(refusalGoldenFor(scenario)); }); } }); @@ -497,7 +519,8 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * catalog does not hold, a dangling one or a row with no definition alike, is * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two - * built-in rows are refused on their `platform` provenance. + * built-in rows an existing database holds are refused on their `platform` + * provenance. */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -528,191 +551,48 @@ const REFUSAL_GOLDEN: Record = { }, }; -/** - * Each recorded row's `label | description`, as the census spells it — one - * entry per name, the same in every organization. - */ -const TEXT: Record = { - everyone: - 'Everyone | Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.', - field_rep: - 'Field Rep | Works the territory', - guest: - 'Guest | Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).', - org_admin: - 'Organization Admin | Organization administrator within a tenant.', - org_member: - 'Organization Member | Organization member within a tenant.', - org_owner: - 'Organization Owner | Organization owner within a tenant.', - platform_admin: - 'Platform Admin | Platform operator (SaaS admin). NOT a tenant user role.', - door_authored: - 'Door Authored | -', -}; - -/** The three postures as they are: recorded before the declarations (module doc), unchanged by S2b. */ -const PLAIN_GOLDEN: Record = { - single: { - census: [ - `everyone | - | platform | true | false | ${TEXT.everyone}`, - `field_rep | - | admin | true | false | ${TEXT.field_rep}`, - `guest | - | platform | true | false | ${TEXT.guest}`, - `org_admin | - | platform | true | false | ${TEXT.org_admin}`, - `org_member | - | platform | true | false | ${TEXT.org_member}`, - `org_owner | - | platform | true | false | ${TEXT.org_owner}`, - `platform_admin | - | platform | true | false | ${TEXT.platform_admin}`, - ], - ledger: [ - 'insert field_rep@-', - 'insert platform_admin@- managed_by=platform', - 'insert org_owner@- managed_by=platform', - 'insert org_admin@- managed_by=platform', - 'insert org_member@- managed_by=platform', - 'insert everyone@- managed_by=platform', - 'insert guest@- managed_by=platform', - ], - }, - 'single + organization': { - census: [ - `everyone | - | platform | true | false | ${TEXT.everyone}`, - `field_rep | - | admin | true | false | ${TEXT.field_rep}`, - `guest | - | platform | true | false | ${TEXT.guest}`, - `org_admin | - | platform | true | false | ${TEXT.org_admin}`, - `org_member | - | platform | true | false | ${TEXT.org_member}`, - `org_owner | - | platform | true | false | ${TEXT.org_owner}`, - `platform_admin | - | platform | true | false | ${TEXT.platform_admin}`, - ], - ledger: [ - 'insert field_rep@-', - 'insert platform_admin@- managed_by=platform', - 'insert org_owner@- managed_by=platform', - 'insert org_admin@- managed_by=platform', - 'insert org_member@- managed_by=platform', - 'insert everyone@- managed_by=platform', - 'insert guest@- managed_by=platform', - ], - }, - walled: { - census: [ - `everyone | org_eq | platform | true | false | ${TEXT.everyone}`, - `everyone | org_late | platform | true | false | ${TEXT.everyone}`, - `field_rep | org_eq | admin | true | false | ${TEXT.field_rep}`, - `field_rep | org_late | admin | true | false | ${TEXT.field_rep}`, - `guest | org_eq | platform | true | false | ${TEXT.guest}`, - `guest | org_late | platform | true | false | ${TEXT.guest}`, - `org_admin | org_eq | platform | true | false | ${TEXT.org_admin}`, - `org_admin | org_late | platform | true | false | ${TEXT.org_admin}`, - `org_member | org_eq | platform | true | false | ${TEXT.org_member}`, - `org_member | org_late | platform | true | false | ${TEXT.org_member}`, - `org_owner | org_eq | platform | true | false | ${TEXT.org_owner}`, - `org_owner | org_late | platform | true | false | ${TEXT.org_owner}`, - `platform_admin | org_eq | platform | true | false | ${TEXT.platform_admin}`, - `platform_admin | org_late | platform | true | false | ${TEXT.platform_admin}`, - ], - ledger: [ - 'insert field_rep@org_eq', - 'insert platform_admin@org_eq managed_by=platform', - 'insert org_owner@org_eq managed_by=platform', - 'insert org_admin@org_eq managed_by=platform', - 'insert org_member@org_eq managed_by=platform', - 'insert everyone@org_eq managed_by=platform', - 'insert guest@org_eq managed_by=platform', - 'insert field_rep@org_late', - 'insert platform_admin@org_late managed_by=platform', - 'insert org_owner@org_late managed_by=platform', - 'insert org_admin@org_late managed_by=platform', - 'insert org_member@org_late managed_by=platform', - 'insert everyone@org_late managed_by=platform', - 'insert guest@org_late managed_by=platform', - ], - }, -}; +/** {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing database's rows. */ +function refusalGoldenFor(scenario: Scenario): Record { + if (scenario.existingRows) return REFUSAL_GOLDEN; + const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = REFUSAL_GOLDEN; + return fresh; +} /** - * The door-authored scenarios after S2b: the posture's own rows plus the - * door-authored position's, `field_rep` included in every pass — before S2b it - * was missing from all of them (module doc). + * The rows the retired seeders wrote — the six built-ins (`platform`) and the + * stack's declared `field_rep` — as an existing database holds them: one + * organization-less set under `single`, the organization's own set under a + * wall. The text is what those seeders stamped from the same declarations. */ -const DOOR_GOLDEN: Record = { - single: { - census: [ - `door_authored | - | admin | true | false | ${TEXT.door_authored}`, - ...PLAIN_GOLDEN.single.census, - ], - ledger: [ - 'insert door_authored@-', - ...PLAIN_GOLDEN.single.ledger, - ], +const LEGACY_ROWS: ReadonlyArray<{ name: string; label: string; description: string; managed_by?: string }> = [ + { name: 'field_rep', label: 'Field Rep', description: 'Works the territory' }, + { name: 'platform_admin', label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.', managed_by: 'platform' }, + { name: 'org_owner', label: 'Organization Owner', description: 'Organization owner within a tenant.', managed_by: 'platform' }, + { name: 'org_admin', label: 'Organization Admin', description: 'Organization administrator within a tenant.', managed_by: 'platform' }, + { name: 'org_member', label: 'Organization Member', description: 'Organization member within a tenant.', managed_by: 'platform' }, + { + name: 'everyone', + label: 'Everyone', + description: + 'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.', + managed_by: 'platform', }, - 'single + organization': { - census: [ - `door_authored | - | admin | true | false | ${TEXT.door_authored}`, - ...PLAIN_GOLDEN['single + organization'].census, - ], - ledger: [ - 'insert door_authored@-', - ...PLAIN_GOLDEN['single + organization'].ledger, - ], + { + name: 'guest', + label: 'Guest', + description: + 'Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).', + managed_by: 'platform', }, - walled: { - census: [ - `door_authored | org_eq | admin | true | false | ${TEXT.door_authored}`, - `door_authored | org_late | admin | true | false | ${TEXT.door_authored}`, - ...PLAIN_GOLDEN.walled.census, - ], - ledger: [ - 'insert door_authored@org_eq', - 'insert field_rep@org_eq', - 'insert platform_admin@org_eq managed_by=platform', - 'insert org_owner@org_eq managed_by=platform', - 'insert org_admin@org_eq managed_by=platform', - 'insert org_member@org_eq managed_by=platform', - 'insert everyone@org_eq managed_by=platform', - 'insert guest@org_eq managed_by=platform', - 'insert door_authored@org_late', - 'insert field_rep@org_late', - 'insert platform_admin@org_late managed_by=platform', - 'insert org_owner@org_late managed_by=platform', - 'insert org_admin@org_late managed_by=platform', - 'insert org_member@org_late managed_by=platform', - 'insert everyone@org_late managed_by=platform', - 'insert guest@org_late managed_by=platform', - ], - }, -}; - -/** - * The census and the ledger, per scenario. A stored definition under a - * built-in name changes neither: the declared seeder writes no row for the - * name and restamps none, and the built-in rows are exactly what - * `bootstrapBuiltinRoles` writes from the declarations. - */ -const CATALOG_GOLDEN = Object.fromEntries( - (Object.keys(POSTURES) as PostureName[]).flatMap((name) => [ - [name, PLAIN_GOLDEN[name]], - [`${name}, ${DOOR}`, DOOR_GOLDEN[name]], - [`${name}, ${SHADOW}`, PLAIN_GOLDEN[name]], - ]), -) as Record; +]; -/** - * The door-authored position's own rows and writes — the same before S2b - * (the registry-only read) and after it. - */ -const DOOR_AUTHORED_ROWS: Record<'single' | 'walled', { census: string[]; ledger: string[] }> = { - single: { - census: [`door_authored | - | admin | true | false | ${TEXT.door_authored}`], - ledger: ['insert door_authored@-'], - }, - walled: { - census: [ - `door_authored | org_eq | admin | true | false | ${TEXT.door_authored}`, - `door_authored | org_late | admin | true | false | ${TEXT.door_authored}`, - ], - ledger: ['insert door_authored@org_eq', 'insert door_authored@org_late'], - }, -}; +/** {@link LEGACY_ROWS} as the census spells them, for a scenario's posture. */ +function legacyCensus(scenario: Scenario): string[] { + const organization = scenario.posture === 'isolated' ? ORG : '-'; + return LEGACY_ROWS + .map((r) => [r.name, organization, r.managed_by ?? 'admin', true, false, r.label, r.description].join(' | ')) + .sort(); +} /** * Recorded before the declarations (module doc). `field_default` and diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 0492ade12a2..6936a4c5710 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -329,11 +329,14 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names it('under a walled posture, an organization-authored position has no catalog home: listed by name and organization; the organization\'s built-in copies are not', async () => { const b = await boot({ posture: 'isolated' }); - // The organizations' own copies of the built-ins, which the walled - // catalog seeds when an organization is created. - const seeded = (await b.engine.find('sys_position', { where: { name: 'everyone' }, context: SYS })) as any[]; - expect(seeded.map((r) => r.organization_id).sort(), 'PRECONDITION: each organization holds its built-in copies') - .toEqual([ORG, OTHER_ORG]); + // The organizations' own copies of the built-ins, as an existing database + // holds them: the retired built-in seeder wrote them per organization, and + // since ADR-0131 D2 no boot writes them (stage 6a). + await insertRows(b, 'sys_position', [ + { id: 'pos_everyone_own', name: 'everyone', label: 'Everyone', managed_by: 'platform', organization_id: ORG }, + { id: 'pos_everyone_other', name: 'everyone', label: 'Everyone', managed_by: 'platform', organization_id: OTHER_ORG }, + { id: 'pos_org_member_own', name: 'org_member', label: 'Organization Member', managed_by: 'platform', organization_id: ORG }, + ]); await insertRows(b, 'sys_position', [ { id: 'pos_regional_own', name: 'regional_lead', label: 'Regional lead', organization_id: ORG }, { id: 'pos_regional_other', name: 'regional_lead', label: 'Regional lead', organization_id: OTHER_ORG }, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a6ef33c204d..8734dbe59a1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4409,11 +4409,11 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 /** * Boot the plugin far enough to run `runBootstrap` to completion, with the - * declarations served off the METADATA SERVICE — the seeder's own fallback - * source, and the one that answers at this moment in the boot: the anchor - * binding runs BEFORE `bootstrapDeclaredCapabilities` writes any - * `sys_capability` row. (The registry door is pinned on the other consumer, - * `suggested-audience-bindings.test.ts`.) + * declarations served off the METADATA SERVICE — the declared-capability + * reader's fallback source. (The registry door is pinned on the other + * consumer, `suggested-audience-bindings.test.ts`.) [ADR-0131 D3/D4, stage + * 6a] The `everyone` baseline is the anchor's declared `permissionSets`; the + * boot writes no junction row for it. */ async function boot(token: string, declaredCapabilities: any[]) { const tables: Record = { @@ -4504,28 +4504,19 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), ).toHaveLength(1); - const refusals = ctx.logger.warn.mock.calls.filter((c: any[]) => - String(c[0]).includes('refusing to bind fallback set to everyone'), - ); const bindings = inserts.filter((i) => i.object === 'sys_position_permission_set'); 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 }; + return { plugin, ql, ctx, bindings, declaredRefusals, everyoneSets }; } - it('binds the isDefault set when the stack declares its systemPermissions token', async () => { - const { bindings, refusals } = await boot('crm.export_pipeline', [ - { name: 'crm.export_pipeline', label: 'Export Pipeline' }, - ]); - expect(refusals).toHaveLength(0); - expect(bindings).toHaveLength(1); - 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. + // [ADR-0131 D3/D4] The `everyone` definition names the baseline, judged by + // the high-privilege check against the stack's declared capabilities. The + // undeclared leg is the control: declarations exist and name a DIFFERENT + // capability — it is MEMBERSHIP that excuses a token, not the presence of a + // declaration list. 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); @@ -4536,21 +4527,19 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 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. - const { bindings, refusals } = await boot('crm.settle_ledger', [ - { name: 'crm.export_pipeline', label: 'Export Pipeline' }, - ]); - expect(bindings).toHaveLength(0); - expect(refusals).toHaveLength(1); - expect(refusals[0][1]).toMatchObject({ set: 'app_member_default', offending: 'system permissions' }); + it('still refuses a PLATFORM capability the stack declares a same-named capability for', async () => { + const { declaredRefusals, everyoneSets } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); + expect(declaredRefusals).toHaveLength(1); + expect(everyoneSets).toEqual(['member_default']); }); - it('still refuses a PLATFORM capability the stack declares a same-named capability for', async () => { - const { bindings, refusals } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); - expect(bindings).toHaveLength(0); - expect(refusals).toHaveLength(1); + // [Stage 6a] The baseline junction writer is retired: no boot writes a + // `sys_position_permission_set` row, declared token or not. + it('writes no junction row for the baseline', async () => { + for (const token of ['crm.export_pipeline', 'crm.settle_ledger']) { + const { bindings } = await boot(token, [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + expect(bindings).toEqual([]); + } }); // ── The engine-side write gate (`sys_position_permission_set` insert) ── From ce09c7244cd6bb0e22ecda6a62eb5861874f00f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:09:20 +0000 Subject: [PATCH 4/7] docs(plugin-security): say where positions live now that no boot writes a sys_position row; drop the deleted test from two gate baselines Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- content/docs/permissions/authorization.mdx | 10 +++-- .../docs/permissions/permission-metadata.mdx | 2 +- content/docs/permissions/positions.mdx | 4 +- content/docs/permissions/system-context.mdx | 4 +- .../src/bootstrap-platform-admin.ts | 10 ++--- .../src/builtin-positions.test.ts | 7 +-- .../plugin-security/src/builtin-positions.ts | 45 ++++++------------- .../src/objects/reserved-identity-names.ts | 9 ++-- .../src/position-catalog-refusal.ts | 7 +-- .../src/suggested-audience-bindings.ts | 2 +- .../dogfood/test/authz-conformance.matrix.ts | 4 +- ...e-declarative-rbac-seeding.dogfood.test.ts | 27 ++++++----- scripts/engine-double-contract.baseline.json | 8 ---- scripts/objectql-double-limit.baseline.json | 3 -- 14 files changed, 59 insertions(+), 83 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 02dda73fb01..108e61655c4 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -164,9 +164,11 @@ package (metadata); subject bindings and env-specific values stay as config.** env-admin-only construct. - This is what makes package **uninstall** well-defined (drop the package's own sets) and the objectui Access matrix scopable to `{ packageId }`. -- Declared positions and sharing rules seed the same way - (`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded - array is exactly the inert-metadata smell ADR-0078 prohibits. +- Declared sharing rules seed the same way (ADR-0057 D6) — a + declarable-but-never-seeded array is exactly the inert-metadata smell + ADR-0078 prohibits. Declared positions need no row at all: the security + catalog serves them from the registry, and no boot step writes a + `sys_position` row (ADR-0131 D2). ### Package capability declaration (ADR-0066 D1) @@ -273,7 +275,7 @@ one of two doors, each writing only what it owns: | Stage | What holds today | Owned by | |---|---|---| -| 1 · Package development | Zod-validated authoring; positions / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates | +| 1 · Package development | Zod-validated authoring; sharingRules / permissions seeded at boot with provenance; positions served from the registry, with no row (ADR-0131 D2) | ADR-0057 D6, ADR-0086 D5, ADR-0131 D2, ADR-0049/0078 gates | | 2 · Distribution / install / upgrade / uninstall | Install-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-defined | ADR-0025/0028/0048/0086 | | 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 | | 4 · Runtime enforcement | The six-gate chain above; each enforced primitive holds a row naming its enforcement site — the matrix file is the authority on the count and on which rows CI can check | ADR-0056 D10 matrix | diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 70e02abf2b4..bad26f4188f 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -220,7 +220,7 @@ The built-in context variables available in RLS `using`/`check` clauses include (ADR-0068). `positions` is a **string array** — the canonical assignment field (D3 reserves the word "role"; there is no `current_user.roles`) — so test membership with CEL: `'org_admin' in current_user.positions` or -`current_user.positions.exists(p, p == 'sales_manager')`. The framework-seeded +`current_user.positions.exists(p, p == 'sales_manager')`. The framework-declared built-in position names are `platform_admin`, `org_owner`, `org_admin`, and `org_member`; `everyone`/`guest` are the implicit audience anchors (ADR-0090 D9). (`current_user.isPlatformAdmin` is the `PLATFORM_ADMIN` standing of diff --git a/content/docs/permissions/positions.mdx b/content/docs/permissions/positions.mdx index 3856ee87d39..a7c5ab2ebf6 100644 --- a/content/docs/permissions/positions.mdx +++ b/content/docs/permissions/positions.mdx @@ -80,7 +80,9 @@ including holders of plain CRUD on the table — is denied. ## Built-in identity positions (ADR-0068) `current_user.positions` is a string array — the only canonical field (there -is no singular `current_user.position` at runtime). Framework-seeded names: +is no singular `current_user.position` at runtime). The built-in names are +declared by the platform — served by the security catalog, with no +`sys_position` row (ADR-0131 D2): | Position | Meaning | |:---|:---| diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3e9919c52a3..4963b325738 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,7 +109,7 @@ that silently does not happen. | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | | 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | -| 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the seeders and the package door write rows for definitions that already have their home, untouched. Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | +| 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the package door writes rows for definitions that already have their home, untouched (no boot seeder writes a `sys_position` row, ADR-0131 D2). Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | | 14 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin` | @@ -129,7 +129,7 @@ that silently does not happen. | 21b | **`readonly` strip bypassed — the write PREVIEW (`ObjectQL.validate`)** | objectql | Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no `readonly` drop and judges the value a system write would store. The `computed` strip of a `formula` value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller | `packages/objectql/src/engine.ts#validate` | | 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` | | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` | -| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` | +| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` | | 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` | | 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` | diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 4844e128c4e..894a5ee48f6 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -53,9 +53,9 @@ * The seed insert stamps `managed_by: 'platform'` **explicitly**, so a fresh * install's default sets are platform-owned and `os meta resync` reconciles * them — which is what #2705 built the resync flag for. This also puts the - * seeder in line with its two siblings in this package, - * `bootstrap-builtin-positions.ts` and `bootstrap-system-capabilities.ts`, - * which have always stamped `'platform'` rather than inheriting a default. + * seeder in line with its two siblings this package had (the built-in position + * and system capability seeders, both since retired by ADR-0131), which always + * stamped `'platform'` rather than inheriting a default. * * ⚠️ **Installs created BEFORE that ruling carry `'admin'` on these rows.** * The pre-ruling insert omitted `managed_by` altogether, so the value came from @@ -880,8 +880,8 @@ export async function bootstrapPlatformAdmin( // default sets are stored indistinguishably from admin-authored ones, so // `os meta resync` skips every single one of them (measured in commit db923a3a8: // resynced 0 / resyncSkipped 8) -- the exact inverse of what #2705 built - // the flag for. Matches `bootstrap-builtin-positions.ts` and - // `bootstrap-system-capabilities.ts`, which already stamp `'platform'`. + // the flag for. Matched the built-in position and system capability + // seeders (both since retired by ADR-0131), which stamped `'platform'`. managed_by: 'platform', }, refusals); if (created?.id) seeded[ps.name] = created.id; diff --git a/packages/plugins/plugin-security/src/builtin-positions.test.ts b/packages/plugins/plugin-security/src/builtin-positions.test.ts index eb681fe7222..b746d7612c5 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest'; import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_METADATA, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; import { PositionSchema } from '@objectstack/spec/identity'; -import { isBuiltinPositionName, registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js'; +import { registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js'; describe('securityBuiltinPositions', () => { it('declares the four identity names, then the two audience anchors, and nothing else', () => { @@ -31,11 +31,6 @@ describe('securityBuiltinPositions', () => { expect(PositionSchema.safeParse(declaration).success, declaration.name).toBe(true); } }); - - it('answers membership by exact name only', () => { - for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) expect(isBuiltinPositionName(name)).toBe(true); - for (const name of ['Everyone', 'org_admins', 'field_rep', '', undefined, 42]) expect(isBuiltinPositionName(name)).toBe(false); - }); }); describe('registerBuiltinPositions', () => { diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index e7eb9539209..1d52bc0de21 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -5,28 +5,18 @@ * the two audience anchors (ADR-0090 D5/D9) — declared ONCE, as position * metadata this plugin owns. * - * ## One list, two consumers + * ## One list, one home * - * {@link securityBuiltinPositions} is the only place the six are listed: - * - * 1. `SecurityPlugin.start` declares them to the engine registry - * ({@link registerBuiltinPositions}), under this plugin's package id, so - * the security catalog read (`createSecurityCatalogReader`, - * `@objectstack/core`) and the metadata door (`GET /api/v1/meta/position`) - * list them beside every other declared position (ADR-0131 D2: built-ins - * and audience anchors are declared metadata — the C3 slice the C2 - * execution card pulls forward). - * 2. `bootstrapBuiltinRoles` seeds their `sys_position` rows from this same - * list, exactly as it seeded them from its own code list before: the same - * names, the same organizations, `managed_by: 'platform'`, `active: true`, - * `is_default: false`. - * - * And one exclusion: `bootstrapDeclaredPositions` skips every name here - * ({@link isBuiltinPositionName}). It seeds from the security catalog read, - * which lists the six now that they are declared; taken there, they would get - * a copy without the `platform` provenance ahead of the built-in pass, which - * then restamps it. The exclusion is by name, so an environment-stored - * definition that shadows one of the six at read is skipped as well. + * {@link securityBuiltinPositions} is the only place the six are listed. + * `SecurityPlugin.start` declares them to the engine registry + * ({@link registerBuiltinPositions}), under this plugin's package id, so the + * security catalog read (`createSecurityCatalogReader`, `@objectstack/core`) + * and the metadata door (`GET /api/v1/meta/position`) list them beside every + * other declared position (ADR-0131 D2: built-ins and audience anchors are + * declared metadata). The declaration is their one home: no boot step writes a + * `sys_position` row for them — the built-in and declared position seeders are + * retired (ADR-0131 D2, C3 stage 6a). Rows an earlier boot wrote stay on an + * existing database until the upgrade ceremony removes them (C7). * * ## Why the engine registry, and not the manifest's `positions` key * @@ -49,9 +39,9 @@ * * ## What a declaration here does NOT carry * - * 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 + * Identity and display: `name`, `label`, `description`. Activation is the + * activation ledger's (`sys_metadata_activation`), and provenance is the + * owning package the registry stamps. 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. @@ -109,13 +99,6 @@ export const securityBuiltinPositions: readonly BuiltinPositionDeclaration[] = O ...AUDIENCE_ANCHOR_POSITIONS.map((name) => Object.freeze({ name, ...AUDIENCE_ANCHOR_METADATA[name] })), ]); -const BUILTIN_POSITION_NAMES: ReadonlySet = new Set(securityBuiltinPositions.map((p) => p.name)); - -/** Is `name` one of the six built-in positions this plugin declares? Exact match. */ -export function isBuiltinPositionName(name: unknown): boolean { - return typeof name === 'string' && BUILTIN_POSITION_NAMES.has(name); -} - /** * The engine registry's registration seam, as this module uses it — ObjectQL's * `SchemaRegistry` satisfies it as it stands. Not on the engine's published diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts index b730cc49a53..3054f834940 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts @@ -7,10 +7,11 @@ * They are a normalized PROJECTION into `current_user.positions`, and their * sources of truth are elsewhere: the unscoped `admin_full_access` grant for * `platform_admin`, `sys_member.role` for the `org_*` trio. Nothing in the - * platform ever writes one of these names into a tenant-authored row — - * `bootstrapBuiltinRoles` seeds the `sys_position` CATALOG rows (stamped - * `managed_by = 'platform'`) and no writer anywhere creates a - * `sys_user_position` assignment spelling one. + * platform ever writes one of these names into a tenant-authored row — the + * retired built-in seeder wrote the `sys_position` CATALOG rows an existing + * database still holds (stamped `managed_by = 'platform'`; since ADR-0131 D2 the + * names are declared metadata and no boot writes a row), and no writer anywhere + * creates a `sys_user_position` assignment spelling one. * * ## Why this file exists at all * diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.ts index 1ffa2986d5f..04c6fe82a24 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.ts @@ -68,11 +68,8 @@ * * - every `isSystem` write — the stand-down the engine's own lookup refusal and * this plugin's whole security middleware take. That covers the seed loader, - * which on a fresh `single`-posture boot writes `stack.data` from - * `AppPlugin.start()`, BEFORE `kernel:ready` seeds the declared catalog: a - * refusal there would turn every authored assignment seed into a failed boot - * that succeeds on the second one. It also covers invitation acceptance and - * the platform's own bootstraps; + * which writes `stack.data` from `AppPlugin.start()` under a system context, + * invitation acceptance and the platform's own bootstraps; * - a value the engine answers itself, and only those: `null` or a blank * string (`required`); one whose `String()` form is longer than the column * (`max_length`); and an operator object, a plain object carrying a declared diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 2e7d8d5596e..2c433bc539b 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -439,7 +439,7 @@ export function collectDeclaredSuggestions( * was checked against could belong to a different tenant entirely. * * Scoping it does not cost the stock deployment its anchor: measured, the - * `everyone` row `bootstrapBuiltinRoles` seeds with a bare system context + * `everyone` row the retired built-in seeder wrote with a bare system context * (`organization_id` NULL) IS visible under `{ isSystem: true, tenantId: X }`, * because ADR-0120 D3's platform bucket expands the tenant predicate to * `organization_id = :tenant OR organization_id IS NULL`. What the scope diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 3b28d7410bd..7de83bb2fa3 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -382,8 +382,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // ── ADR-0057 — ERP authorization core (enforced + e2e proven) ────────── { id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced', enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' }, - { id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot', state: 'enforced', - enforcement: 'plugin-security bootstrapDeclaredPositions + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, + { id: 'declarative-rbac-seeding', summary: 'stack-declared positions served by the security catalog (no row) + sharingRules seeded at boot', state: 'enforced', + enforcement: 'core createSecurityCatalogReader (positions from the registry, ADR-0131 D2) + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets seeded into sys_permission_set with package provenance (packageId + managed_by)', state: 'enforced', enforcement: 'plugin-security bootstrapDeclaredPermissions — ADR-0086 D5 (managed_by:package re-seeded on boot/upgrade; env-authored platform/user/legacy rows never clobbered); provenance fields ADR-0086 D3 (spec PermissionSetSchema.packageId/managedBy + sys_permission_set.package_id/managed_by)', proof: 'showcase-permission-seeding.dogfood.test.ts', note: 'Closes the ADR-0078 inert-metadata violation for stack.permissions — declared sets were runtime-enforced via the registry but never materialized as records (invisible to the admin surface, uninstall undefined). This row pins the seeding so it cannot silently regress to inert.' }, diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index f50a21936e8..77fa0c7e711 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -1,9 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// ADR-0057 D6 / #2077 — stack-declared `roles` + `sharingRules` are seeded into -// sys_position / sys_sharing_rule at boot, so they stop being decorative. The issue -// reported booting the showcase yielded sys_position count = 0 and sys_sharing_rule -// count = 0. This proves the opposite, plus the spec→runtime translation. +// ADR-0057 D6 / #2077 — stack-declared positions + `sharingRules` are not +// decorative. The issue reported booting the showcase yielded sys_position +// count = 0 and sys_sharing_rule count = 0. Declared sharing rules are seeded +// into sys_sharing_rule at boot, with the spec→runtime translation. Declared +// positions are served by the security catalog from the registry — ADR-0131 +// D2 retired the position seeders, so the boot writes no sys_position row and +// the catalog read is where a declared position is live. // // @proof: showcase-declarative-rbac-seeding // @@ -12,6 +15,7 @@ // authz-row: declarative-rbac-seeding import { describe, it, expect, beforeAll } from 'vitest'; +import { createSecurityCatalogReader } from '@objectstack/core'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; @@ -25,9 +29,9 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { ql = await stack.kernel.getServiceAsync('objectql'); }, 60_000); - it('declared roles land in sys_position (was count = 0)', async () => { - const roles = await ql.find('sys_position', { where: {}, context: { isSystem: true } }); - const names = (roles ?? []).map((r: any) => r.name); + it('declared positions are served by the security catalog (was count = 0)', async () => { + const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') as any }); + const names = (await reader.list('position')).map((e) => e.name); expect(names, 'showcase declares contributor/manager/exec').toEqual( expect.arrayContaining(['contributor', 'manager', 'exec']), ); @@ -78,8 +82,11 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { expect(criteria).toEqual({ $and: [{ stage: 'qualified' }, { company: 'Northwind' }] }); }); - it('re-seed is idempotent (no duplicate rows on a second boot)', async () => { - const roles = await ql.find('sys_position', { where: { name: 'manager' }, context: { isSystem: true } }); - expect((roles ?? []).length, 'exactly one manager role').toBe(1); + // [ADR-0131 D2] The boot writes no position row — neither for the declared + // positions nor for the six built-ins the platform declares. + it('the boot writes no sys_position row for a declared or built-in position', async () => { + const names = ['contributor', 'manager', 'exec', 'platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']; + const rows = await ql.find('sys_position', { where: { name: { $in: names } }, context: { isSystem: true } }); + expect((rows ?? []).map((r: any) => r.name)).toEqual([]); }); }); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 1bf69c0399c..4b6386d5d69 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -408,14 +408,6 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 21. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts", "verb": "update", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index bd41848d76d..bf011c8d43d 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -588,9 +588,6 @@ "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, From 5f1181bb1328f75d0cb5f06d3bff82db757ed10b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:22:30 +0000 Subject: [PATCH 5/7] chore(changeset): plugin-security writes no sys_position row at boot Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .changeset/15204-s6a-position-seeders.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/15204-s6a-position-seeders.md diff --git a/.changeset/15204-s6a-position-seeders.md b/.changeset/15204-s6a-position-seeders.md new file mode 100644 index 00000000000..70571ee405f --- /dev/null +++ b/.changeset/15204-s6a-position-seeders.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security): the boot no longer writes `sys_position` rows — the built-in and declared position seeders and the `everyone` baseline junction writer are deleted + +Clause-②: no + +`SecurityPlugin` no longer seeds a `sys_position` row for the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) or for a position a stack declares, and no longer writes a `sys_position_permission_set` row binding the baseline sets to `everyone`. Positions are served by the security catalog (ADR-0131 D2): the built-ins are this plugin's declarations, a stack's positions are registry items, and `everyone`'s baseline — the app's default set and `member_default`, minus any set the high-privilege check refuses — is that anchor's declared `permissionSets`. `GET /api/v1/meta/position` lists all of them, and assigning a declared position needs no row. + +No authorization decision changes: since the resolver reads position definitions, no grant reads a `sys_position` or `sys_position_permission_set` row, and a fresh database and an existing one grant every principal the same. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + +FROM → TO: + +- FROM reading the built-in or declared positions from `sys_position` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/position`. +- FROM the `everyone` → baseline `sys_position_permission_set` rows → TO the `everyone` definition's `permissionSets`. From 62b5cd4909270349a9175c0f5fc561d1a64d4cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:58:07 +0000 Subject: [PATCH 6/7] test(dogfood): read positions from the security catalog and stage an existing database's rows where a gate judges a row; refresh the census and the double ledgers Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 40 +++---- ...08-tenant-audit-write-call-sites.counts.md | 21 ++-- ...clared-position-provenance.dogfood.test.ts | 107 ++++++++++-------- ...apps-and-everyone-baseline.dogfood.test.ts | 19 ++-- ...ion-held-name-setup-create.dogfood.test.ts | 3 +- ...howcase-d7-default-profile.dogfood.test.ts | 27 ++--- ...e-declarative-rbac-seeding.dogfood.test.ts | 10 +- ...ase-invoice-seed-isolation.dogfood.test.ts | 24 +--- .../showcase-permission-zoo.dogfood.test.ts | 27 +++-- scripts/engine-double-contract.baseline.json | 8 -- scripts/engine-double-contract.pinned.json | 5 - scripts/objectql-double-limit.baseline.json | 3 - 12 files changed, 136 insertions(+), 158 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 893f8fda28f..b8a3d07561e 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 51 of them — just over a fifth +typed `any` have no type to read, and there are 47 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -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 **53 of the 239 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 234 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 | **239** | +| 175 write call sites | quoted in the merged changeset | **234** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 239** decidable, **79** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 105 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 234** decidable, **75** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 100 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 @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 51 sites reached +one, and both are counted in the generated tables below: the 47 sites reached through an erased (`any`) receiver, and the 53 that name their object through a `const` rather than inline. An instrument that read either the way a person does 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 -126 of 239 (53%) as decidably elevated, with 105 more whose elevation is a +126 of 234 (54%) as decidably elevated, with 100 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 / 239`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 234`, 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" — **32 further sites** have an options argument this @@ -223,30 +223,30 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **239** | -| …whose object name is statically decidable | 160 | -| …whose object name is chosen at run time | 79 | -| …against an object with tenancy ENABLED | 159 | +| write call sites on the application surface | **234** | +| …whose object name is statically decidable | 159 | +| …whose object name is chosen at run time | 75 | +| …against an object with tenancy ENABLED | 158 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 178 | +| 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 | 53 | | …of those, against a decidably tenancy-enabled object | 32 | | threading a decidably ELEVATED (`isSystem`) context | 126 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 105 | +| threading a context whose elevation is a run-time fact | 100 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 188 | +| receiver carried a readable engine type | 187 | | receiver erased, placed by the object NAME | 33 | -| receiver erased, placed by an `object: string` PARAMETER | 13 | +| receiver erased, placed by an `object: string` PARAMETER | 9 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 107 | +| object name spelled inline | 106 | | object name spelled through a `const` | 53 | -| object name is an `object: string` parameter | 17 | +| object name is an `object: string` parameter | 13 | | object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,11 +297,11 @@ 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-10 at `b88b35678`. +Measured on 2026-10-11 at `5f1181bb1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 631 | +| tracked non-test sources scanned | 636 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | 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 e274581676d..b7d06120908 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 | 239 | -| Object name statically decidable | 160 | -| Object name chosen at run time | 79 | -| Against a tenancy-enabled object | 159 | +| Write call sites | 234 | +| Object name statically decidable | 159 | +| Object name chosen at run time | 75 | +| Against a tenancy-enabled object | 158 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 178 | +| Threading a tenant context | 173 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | | Threading a decidably elevated context | 126 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 105 | +| Threading a context of undecidable elevation | 100 | ## Subtractions the census could NOT defend — enforced @@ -90,11 +90,11 @@ 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-10 at `b88b35678`. +Measured on 2026-10-11 at `5f1181bb1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 631 | +| tracked non-test sources scanned | 636 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -159,10 +159,6 @@ Measured on 2026-10-10 at `b88b35678`. | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | @@ -178,7 +174,6 @@ Measured on 2026-10-10 at `b88b35678`. | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/security-plugin.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 2 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts index ffb8ad12795..0068380fded 100644 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -9,36 +9,36 @@ // // The declared-position seeder wrote its rows with no provenance stamp, so they // carried the object default (`managed_by: 'admin'`) and the system-row write -// gate did not protect them. Measured on `origin/main` before the fix, through -// this file's own steps: `PATCH /api/v1/data/sys_position/:id` of `manager`'s -// label answered 200 and the row carried the new label; the next boot wrote the -// declared label back over it, with no message. The metadata door refused the -// same edit (403 `NOT_OVERRIDABLE`). +// gate did not protect them: a data-door label edit of `manager` answered 200, +// and the next boot wrote the declared label back over it, with no message. // // ## The rule now // -// The seeder stamps `managed_by: 'package'`, and a package position row is -// locked the way a packaged permission set is (`permission-set-projection.ts`, -// #4669): a patch touching only row state (`active`, `is_default` — Setup's -// Activate, Deactivate and Set as Default) passes; a definition column -// (`label`, `delegatable`, …) and a delete are refused with the gate's code. +// [ADR-0131 D2, stage 6a] No boot writes a `sys_position` row: the declared +// and built-in position seeders are retired, and a declared position lives in +// the registry. The rows an earlier boot wrote stay on an existing database +// (C7 removes them), stamped `managed_by: 'package'` by the seeder this file +// once pinned, and the system-row write gate still guards them: a patch +// touching only row state (`active`, `is_default` — Setup's Activate, +// Deactivate and Set as Default) passes; a definition column (`label`, +// `delegatable`, …) and a delete are refused with the gate's code. This file +// stages those rows itself ({@link stageExistingRows}), the way the retired +// seeder left them. // // ## What each case pins // -// - precondition: the showcase package holds the name, and the metadata door -// refuses an edit of it; -// - the declared row carries `managed_by: 'package'`; a label edit, a -// `delegatable` edit and a delete are refused with the gate's code and -// leave the row as it was; +// - precondition: the showcase package holds the name, the metadata door +// refuses an edit of it, and the boot wrote no position row; +// - a staged declared row: a label edit, a `delegatable` edit and a delete +// are refused with the gate's code and leave the row as it was; // - Deactivate, Activate and Set as Default answer 200; a multi-row // (`updateMany`) row-state patch lands, a multi-row label patch is refused; -// - after a cold boot: the declared label and the refused columns stand, the -// row-state writes persisted, an upgraded deployment's row (stamped `admin` -// before the fix) is re-stamped and refused the same way; -// - controls: an administrator-authored position and an environment-authored -// one (saved through the metadata door) stay editable; a built-in refuses -// even a bare `{ active }` patch; the seeder's own system write still -// refreshes a declared row's display text from the declaration. +// - after a cold boot: the boot writes nothing — the refused columns stand, +// the row-state writes persisted, a row stamped `admin` stays `admin`, a +// drifted label stays drifted, and a position saved through the metadata +// door gets no row (the catalog serves it); +// - controls: an administrator-authored position stays editable; a built-in +// row refuses even a bare `{ active }` patch. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -74,6 +74,8 @@ const DRIFTED = 'field_ops_delegate'; const ADMIN_AUTHORED = 'p22360_admin_authored'; const ENV_AUTHORED = 'p22360_env_authored'; const REFUSED = { status: 403, code: 'PERMISSION_DENIED' }; +/** Every declared row the cases read. */ +const STAGED = [EDITED, DELETED, DEACTIVATED, ACTIVATED, DEFAULTED, ...BULK, UPGRADED, DRIFTED]; describe('[#22360] a package-declared position: definition locked at the data door, row state switchable, across a cold boot (showcase)', () => { let prevCwd: string; @@ -103,6 +105,25 @@ describe('[#22360] a package-declared position: definition locked at the data do token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }; + /** Every `sys_position` row, by name, past every scope. */ + const census = async () => + ((await ql.find('sys_position', { where: {}, limit: 1000 }, SYS)) as any[]).map((r) => r.name).sort(); + let bootCensus: string[] = []; + /** + * The rows an existing database holds: what the retired seeders wrote — a + * declared position stamped `package` with its declared label, the + * `everyone` anchor stamped `platform`. + */ + const stageExistingRows = async () => { + for (const name of STAGED) { + await ql.insert('sys_position', { + id: `pos_${name}`, name, label: declaredLabel(name), managed_by: 'package', active: true, is_default: false, + }, SYS); + } + await ql.insert('sys_position', { + id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true, is_default: false, + }, SYS); + }; beforeAll(async () => { prevCwd = process.cwd(); @@ -110,6 +131,8 @@ describe('[#22360] a package-declared position: definition locked at the data do dir = mkdtempSync(join(tmpdir(), 'dogfood-22360-')); dbFile = join(dir, 'showcase.db'); await start(); + bootCensus = await census(); + await stageExistingRows(); }, 300_000); afterAll(async () => { @@ -123,12 +146,8 @@ describe('[#22360] a package-declared position: definition locked at the data do expect((ql.registry.getArtifactItem('position', EDITED) as any)?._packageId).toBe(SHOWCASE_PACKAGE); const put = await call('PUT', `/meta/position/${EDITED}`, { name: EDITED, label: 'Manager (metadata door)' }); expect(outcome(put), JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - }); - - it('the declared row carries package provenance', async () => { - const row = await one(EDITED); - expect({ label: row.label, managed_by: row.managed_by }) - .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); + // [ADR-0131 D2] The fresh boot wrote no position row at all. + expect(bootCensus).toEqual([]); }); it('a label edit and a delegatable edit are refused with the gate\'s code, and the row is unchanged', async () => { @@ -204,22 +223,23 @@ describe('[#22360] a package-declared position: definition locked at the data do expect((await one('everyone')).active).toBe(everyone.active); }); - it('after a cold boot: the definition stands, the row-state writes persisted, an upgraded deployment\'s row is re-stamped and refused, the seeder still refreshes display text', async () => { - // The upgraded shape: the row a pre-fix boot wrote (the object default, - // `admin`) carrying a column an administrator set before the upgrade. + it('after a cold boot: the boot writes nothing — the refused columns stand, the row-state writes persisted, and no row is re-stamped, refreshed or added', async () => { + // The upgraded shape: a row stamped with the object default (`admin`) + // carrying a column an administrator set. const upgraded = await one(UPGRADED); await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); - expect([(await one(UPGRADED)).managed_by, (await one(UPGRADED)).delegatable]).toEqual(['admin', true]); // Drifted display text, which only a system write can reach now. await ql.update('sys_position', { id: (await one(DRIFTED)).id, label: 'Field ops delegate (stale)' }, SYS); - // An environment-authored position: a metadata-door save, whose row the - // seeder writes at the next boot. + // An environment-authored position: a metadata-door save. The catalog + // serves it; no boot writes its row. const saved = await call('PUT', `/meta/position/${ENV_AUTHORED}`, { name: ENV_AUTHORED, label: 'Environment authored' }); expect(saved.status, JSON.stringify(saved.json)).toBe(200); + const before = await census(); await stack!.stop(); await start(); + expect(await census()).toEqual(before); const edited = await one(EDITED); expect({ label: edited.label, managed_by: edited.managed_by }) .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); @@ -231,13 +251,12 @@ describe('[#22360] a package-declared position: definition locked at the data do bulk: await Promise.all(BULK.map(async (name) => (await one(name)).active)), }).toEqual({ deactivated: false, activated: true, defaulted: true, bulk: [false, false] }); - const restamped = await one(UPGRADED); - expect({ managed_by: restamped.managed_by, delegatable: restamped.delegatable, label: restamped.label }) - .toEqual({ managed_by: 'package', delegatable: true, label: declaredLabel(UPGRADED) }); - const refused = await patch(UPGRADED, { label: 'Executive (edited in Setup)' }); - expect(outcome(refused), JSON.stringify(refused.json)).toEqual(REFUSED); - - expect((await one(DRIFTED)).label).toBe(declaredLabel(DRIFTED)); + const kept = await one(UPGRADED); + expect({ managed_by: kept.managed_by, delegatable: kept.delegatable }).toEqual({ managed_by: 'admin', delegatable: true }); + expect((await one(DRIFTED)).label).toBe('Field ops delegate (stale)'); + expect(await rows(ENV_AUTHORED)).toEqual([]); + const served = await call('GET', `/meta/position/${ENV_AUTHORED}`); + expect(served.status, JSON.stringify(served.json)).toBe(200); // This case boots the showcase again (`start()`), so it carries the // budget `beforeAll` gives that same `start()`, not vitest's 5000 ms // default. Since #22301 the showcase boots with the providers its @@ -246,12 +265,8 @@ describe('[#22360] a package-declared position: definition locked at the data do // loaded one. }, 300_000); - it('controls after the cold boot: administrator- and environment-authored positions keep unmanaged, editable rows', async () => { + it('control after the cold boot: an administrator-authored position keeps its unmanaged, edited row', async () => { const admin = await one(ADMIN_AUTHORED); expect([admin.label, admin.managed_by]).toEqual(['Admin authored (edited)', 'admin']); - const env = await one(ENV_AUTHORED); - expect(env.managed_by, 'the seeder wrote the environment definition\'s row').toBe('admin'); - const edited = await patch(ENV_AUTHORED, { label: 'Environment authored (edited)' }); - expect(edited.status, JSON.stringify(edited.json)).toBe(200); }); }); 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 4da3a10c1e7..849ae4f9a7b 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 @@ -29,6 +29,7 @@ // @proof: me-apps-and-everyone-baseline import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createSecurityCatalogReader } from '@objectstack/core'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin } from '@objectstack/plugin-security'; @@ -148,15 +149,15 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { }); // ── #2753: the baseline binds to the everyone anchor at bootstrap ──────── - it('bootstrap binds member_default to the everyone position (one channel, D5)', async () => { - const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); - const baseline = await ql.findOne('sys_permission_set', { where: { name: 'member_default' }, context: SYS }); - expect(everyone?.id && baseline?.id, 'anchor + baseline seeded').toBeTruthy(); - const binding = await ql.findOne('sys_position_permission_set', { - where: { position_id: everyone.id, permission_set_id: baseline.id }, - context: SYS, - }); - expect(binding, 'everyone ← member_default binding row exists (bootstrap no longer refuses)').toBeTruthy(); + // [ADR-0131 D2/D3] The binding is the `everyone` definition's + // `permissionSets`, which the resolver reads; the boot writes no + // `sys_position` or junction row for it. + it('bootstrap names member_default on the everyone definition (one channel, D5)', async () => { + const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); + const everyone = await reader.resolve('position', 'everyone'); + expect((everyone?.definition as any)?.permissionSets, 'everyone names member_default (bootstrap no longer refuses)') + .toContain('member_default'); + expect(await ql.find('sys_position', { where: { name: 'everyone' }, context: SYS }), 'no everyone row is written').toEqual([]); }); it('deleting records is no longer a baseline right (anchor-forbidden bit removed)', async () => { diff --git a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts b/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts index 53b90243dc9..c39994861ec 100644 --- a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts +++ b/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts @@ -74,7 +74,8 @@ describe('[ADR-0131 D3] a Setup create or rename into a held position name answe it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses a save of it', async () => { expect(stack!.tenancy().requestedPosture).toBe('single'); expect((ql.registry.getArtifactItem('position', HELD) as any)?._packageId).toBe('com.example.showcase'); - expect(await rowIds(HELD), 'the seeded row').toHaveLength(1); + // [ADR-0131 D2] The package's declaration is the name's one home; the boot writes no row for it. + expect(await rowIds(HELD), 'no row is seeded').toEqual([]); const put = await call('PUT', `/meta/position/${HELD}`, { name: HELD, label: 'Mine' }); expect(outcome(put), JSON.stringify(put.json)).toEqual(DOOR_REFUSAL); }); diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index a76c85fd94c..7060c491032 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -38,6 +38,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; +import { createSecurityCatalogReader } from '@objectstack/core'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; @@ -101,26 +102,18 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = expect(r.status, 'the platform baseline COMPOSES with the declared default, it is not replaced by it').toBe(200); }); - it('and the explain surface tells the same story: BOTH sets are bound to the everyone anchor (#7555)', async () => { + it('and the catalog tells the same story: BOTH sets are named on the everyone definition (#7555)', async () => { // The runtime composing while the anchor carried only the app's set would - // make `security/explain` — and the Setup UI reading the same join table — - // report a narrower default than every request actually applies. The join - // table takes many rows per position; the boot binding writes one per - // baseline name. - const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); - expect(everyone?.id, 'everyone anchor seeded').toBeTruthy(); - const bound: string[] = []; - for (const name of ['showcase_member_default', 'member_default']) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - if (!set?.id) continue; - const row = await ql.findOne('sys_position_permission_set', { - where: { position_id: everyone.id, permission_set_id: set.id }, context: SYS, - }); - if (row) bound.push(name); - } - expect(bound, 'everyone ← app baseline AND platform baseline').toEqual([ + // make `security/explain` report a narrower default than every request + // actually applies. [ADR-0131 D2/D3] The anchor's binding is its + // definition's `permissionSets`, which the resolver reads; the boot writes + // no `sys_position` or junction row for it. + const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); + const everyone = await reader.resolve('position', 'everyone'); + expect((everyone?.definition as any)?.permissionSets, 'everyone ← app baseline AND platform baseline').toEqual([ 'showcase_member_default', 'member_default', ]); + expect(await ql.find('sys_position_permission_set', { where: {}, context: SYS }), 'no junction row is written').toEqual([]); }); }); diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 77fa0c7e711..b43c9692ba5 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -30,7 +30,7 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { }, 60_000); it('declared positions are served by the security catalog (was count = 0)', async () => { - const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') as any }); + const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); const names = (await reader.list('position')).map((e) => e.name); expect(names, 'showcase declares contributor/manager/exec').toEqual( expect.arrayContaining(['contributor', 'manager', 'exec']), @@ -81,12 +81,4 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { const criteria = JSON.parse(contacts.criteria_json); expect(criteria).toEqual({ $and: [{ stage: 'qualified' }, { company: 'Northwind' }] }); }); - - // [ADR-0131 D2] The boot writes no position row — neither for the declared - // positions nor for the six built-ins the platform declares. - it('the boot writes no sys_position row for a declared or built-in position', async () => { - const names = ['contributor', 'manager', 'exec', 'platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']; - const rows = await ql.find('sys_position', { where: { name: { $in: names } }, context: { isSystem: true } }); - expect((rows ?? []).map((r: any) => r.name)).toEqual([]); - }); }); diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index c404c9ef28a..1abdc3e188e 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -80,7 +80,6 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut const token = new Map(); const userId = new Map(); let setId: string; - let positionId: string; const invoice = async (name: string) => { const inv = await ql.findOne('showcase_invoice', { where: { name }, context: SYS }); @@ -101,25 +100,10 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); expect(set?.id, `the app's ${SET} set is seeded by the security bootstrap`).toBeTruthy(); setId = set.id; - const position = await ql.findOne('sys_position', { where: { name: POSITION }, context: SYS }); - expect(position?.id, `the app's ${POSITION} position is seeded`).toBeTruthy(); - positionId = position.id; - - // The position → set edge. The app writes it on `kernel:bootstrapped` from - // its `onEnable` (bind-position-sets.ts); the verify harness boots the stack - // object, which carries no `onEnable`, so the same idempotent write is made - // here — the pair is exactly the app's `contributor → showcase_contributor`. - const bound = await ql.findOne('sys_position_permission_set', { - where: { position_id: positionId, permission_set_id: setId }, - context: SYS, - }); - if (!bound) { - await ql.insert( - 'sys_position_permission_set', - { position_id: positionId, permission_set_id: setId }, - { context: SYS }, - ); - } + // [ADR-0131 D2/D3] The position → set edge is the app's own declaration — + // `contributor` names `showcase_contributor` in its `permissionSets` — and + // the resolver reads it there. No `sys_position` row and no junction row + // is involved: the boot writes neither. for (const p of PERSONAS) { token.set(p.email, await stack.signUp(p.email, 'Member-Pass-123')); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index d9c1d7ca3df..da30fb2176d 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -129,13 +129,26 @@ describe('showcase: ADR-0090 permission-model zoo', () => { expect(names, 'the granting set is attributed').toContain('showcase_auditor'); }); + /** + * [ADR-0131 D2] No boot writes a `sys_position` row. The junction-write + * gates below judge a write naming a ROW, so each case reads the row an + * existing database holds — what the retired seeders wrote — and stages it + * when this (shared) stack has none yet. + */ + const existingPositionRow = async (name: string, managedBy: 'platform' | 'package') => { + const found = await ql.findOne('sys_position', { where: { name }, context: SYS }); + if (found) return found; + await ql.insert('sys_position', { id: `pos_zoo_${name}`, name, label: name, managed_by: managedBy }, { context: SYS }); + return ql.findOne('sys_position', { where: { name }, context: SYS }); + }; + // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { // Bind contributor → showcase_contributor first (admin action; the // delegate has manageBindings: false and must NOT be able to do this). - const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS }); + const pos = await existingPositionRow('contributor', 'package'); const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(pos?.id && ps?.id, 'contributor position + set seeded').toBeTruthy(); + expect(pos?.id && ps?.id, 'contributor position + set rows').toBeTruthy(); const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, permission_set_id: ps.id, @@ -176,7 +189,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { }); it('the delegate cannot re-compose positions (manageBindings: false)', async () => { - const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS }); + const pos = await existingPositionRow('contributor', 'package'); const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, @@ -187,8 +200,8 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // ── ADR-0090 D5/D9: the audience-anchor binding gate ───────────────────── it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { - const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); - expect(everyone, 'built-in everyone position seeded').toBeTruthy(); + const everyone = await existingPositionRow('everyone', 'platform'); + expect(everyone, 'built-in everyone position row').toBeTruthy(); const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: everyone.id, @@ -198,8 +211,8 @@ describe('showcase: ADR-0090 permission-model zoo', () => { }); it('binding the guest-safe set to the guest anchor is accepted', async () => { - const guest = await ql.findOne('sys_position', { where: { name: 'guest' }, context: SYS }); - expect(guest, 'built-in guest position seeded').toBeTruthy(); + const guest = await existingPositionRow('guest', 'platform'); + expect(guest, 'built-in guest position row').toBeTruthy(); const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: guest.id, diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 4b6386d5d69..846d86329a3 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -376,14 +376,6 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 38. The package already depends on @objectstack/objectql (`dependencies`), so this is a one-line pin whenever a batch takes it — deferred here because #5480's slice is the producer-side predicate plus the gate that reads it, and flipping ~100 unmeasured suites red belongs in the per-package batches that follow, exactly as #5629 did for delete. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "open the fake's update with assertEngineUpdateDispatch(data, options) and run the package's suite" }, - { - "file": "packages/plugins/plugin-security/src/audience-anchors.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts", "verb": "delete", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b30be9122b5..59026cc95d5 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3416,11 +3416,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/seed-write-refusal.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index bf011c8d43d..3b6b1fcff4b 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -579,9 +579,6 @@ "packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/audience-anchors.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts": { "unjudged": 1 }, From ac2d982b92d7931aefa2d3205dc9d5088e4be044 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:51:47 +0000 Subject: [PATCH 7/7] chore(regen): regenerate the system-context and tenant-audit censuses on the merged tree; carry this branch's rows 11b/23b and the census prose figures over main's Claude-Session: https://claude.ai/code/session_01YLbobfnaoKFrxZcQZT8rYP Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 40 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 21 ++++------ 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 5361072f54e..c43f2455c27 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 50 of them — just over a fifth +typed `any` have no type to read, and there are 46 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -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 **53 of the 238 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 233 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 | **238** | +| 175 write call sites | quoted in the merged changeset | **233** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **159 of 238** decidable, **79** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 104 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 233** decidable, **75** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 99 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 @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 50 sites reached +one, and both are counted in the generated tables below: the 46 sites reached through an erased (`any`) receiver, and the 53 that name their object through a `const` rather than inline. An instrument that read either the way a person does 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 -126 of 238 (53%) as decidably elevated, with 104 more whose elevation is a +126 of 233 (54%) as decidably elevated, with 99 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 / 238`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 233`, 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" — **32 further sites** have an options argument this @@ -223,30 +223,30 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **238** | -| …whose object name is statically decidable | 159 | -| …whose object name is chosen at run time | 79 | -| …against an object with tenancy ENABLED | 158 | +| write call sites on the application surface | **233** | +| …whose object name is statically decidable | 158 | +| …whose object name is chosen at run time | 75 | +| …against an object with tenancy ENABLED | 157 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 177 | +| threading a tenant context | 172 | | 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 | 53 | | …of those, against a decidably tenancy-enabled object | 32 | | threading a decidably ELEVATED (`isSystem`) context | 126 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 104 | +| threading a context whose elevation is a run-time fact | 99 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 188 | +| receiver carried a readable engine type | 187 | | receiver erased, placed by the object NAME | 32 | -| receiver erased, placed by an `object: string` PARAMETER | 13 | +| receiver erased, placed by an `object: string` PARAMETER | 9 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 106 | +| object name spelled inline | 105 | | object name spelled through a `const` | 53 | -| object name is an `object: string` parameter | 17 | +| object name is an `object: string` parameter | 13 | | object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,11 +297,11 @@ 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-11 at `a18c51496`. +Measured on 2026-10-11 at `64e53f1d0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 637 | +| tracked non-test sources scanned | 633 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | 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 1c1bc005d25..c89a1c122c3 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 | 238 | -| Object name statically decidable | 159 | -| Object name chosen at run time | 79 | -| Against a tenancy-enabled object | 158 | +| Write call sites | 233 | +| Object name statically decidable | 158 | +| Object name chosen at run time | 75 | +| Against a tenancy-enabled object | 157 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 177 | +| Threading a tenant context | 172 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | | Threading a decidably elevated context | 126 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 104 | +| Threading a context of undecidable elevation | 99 | ## Subtractions the census could NOT defend — enforced @@ -90,11 +90,11 @@ 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-11 at `a18c51496`. +Measured on 2026-10-11 at `64e53f1d0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 637 | +| tracked non-test sources scanned | 633 | | engine-shaped types recognised | 72 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -159,10 +159,6 @@ Measured on 2026-10-11 at `a18c51496`. | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | @@ -177,7 +173,6 @@ Measured on 2026-10-11 at `a18c51496`. | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/security-plugin.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 2 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 |