From 7a5ef33459b9f7d381a32d6bc95a559ba7a4eece Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:05:13 +0000 Subject: [PATCH 01/25] wip(plugin-security): retire the declared-permission seeder, drift, refusal diagnostics and managed_by normalizer (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../bootstrap-declared-permissions.test.ts | 655 ------------------ .../src/bootstrap-declared-permissions.ts | 522 -------------- .../src/bootstrap-seed-round-trips.test.ts | 368 ---------- .../src/builtin-capabilities.boot.test.ts | 2 - .../src/declared-capability-context.ts | 26 +- ...lared-permission-reload-projection.test.ts | 160 ----- .../src/engine-find-bare-array.pin.test.ts | 53 +- packages/plugins/plugin-security/src/index.ts | 45 +- .../src/normalize-managed-by.test.ts | 77 -- .../src/normalize-managed-by.ts | 196 ------ .../src/per-organization-catalog.test.ts | 240 +------ .../src/permission-set-drift.test.ts | 379 ---------- .../src/permission-set-drift.ts | 355 ---------- .../src/permission-set-name-collision.test.ts | 255 +------ .../src/read-fault-distinguishability.test.ts | 133 +--- .../plugin-security/src/security-plugin.ts | 344 ++------- ...ed-name-lookup-degradation-audible.test.ts | 12 +- .../src/seed-refusal-diagnostics.ts | 393 ----------- .../walled-platform-bucket-diagnostic.test.ts | 387 ----------- 19 files changed, 115 insertions(+), 4487 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts delete mode 100644 packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts delete mode 100644 packages/plugins/plugin-security/src/normalize-managed-by.test.ts delete mode 100644 packages/plugins/plugin-security/src/normalize-managed-by.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-drift.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-drift.ts delete mode 100644 packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts delete mode 100644 packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts deleted file mode 100644 index 6eeb04de759..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { describe, it, expect, vi } from 'vitest'; -import { - bootstrapDeclaredPermissions, - upsertPackagePermissionSet, -} from './bootstrap-declared-permissions.js'; -import { - PERMISSION_SET_DECLARATION_UNOWNED, - PERMISSION_SET_ROWS_UNREADABLE, -} from './seed-refusal-diagnostics.js'; -import { permissionSetRowFields } from './permission-set-projection.js'; - -/** [#18091] Seeded from this file, for the class pin at the bottom. */ -const HERE = dirname(fileURLToPath(import.meta.url)); - -/** Minimal in-memory ql + registry for sys_permission_set seeding. */ -function makeQl(declared: any[] = []) { - const rows: any[] = []; - return { - rows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') 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_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - fields: { 'crm_lead.amount': { readable: true, editable: false } }, - systemPermissions: ['crm.use'], - _packageId: 'com.example.crm', - ...over, -}); - -describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => { - it('seeds a declared set as a package-managed sys_permission_set row', async () => { - const ql = makeQl([declaredSet()]); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - const row = ql.rows[0]; - expect(row.name).toBe('crm_sales_rep'); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowCreate: true } }); - expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } }); - expect(JSON.parse(row.system_permissions)).toEqual(['crm.use']); - expect(row.active).toBe(true); - }); - - it('is idempotent + upgrade-aware: re-seeds its OWN row to the shipped declaration', async () => { - const ql = makeQl([declaredSet()]); - await bootstrapDeclaredPermissions(ql, undefined); - // simulate a package upgrade changing the shipped grants - (ql as any).registry = { - listItems: () => [declaredSet({ objects: { crm_lead: { allowRead: true } } })], - }; - const r2 = await bootstrapDeclaredPermissions(ql, undefined); - expect(r2.seeded).toBe(0); - expect(r2.updated).toBe(1); - expect(ql.rows.length).toBe(1); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('never clobbers env-authored rows (platform/user/legacy provenance)', async () => { - const ql = makeQl([declaredSet({ name: 'member_default' })]); - // pre-existing row WITHOUT provenance (legacy / bootstrapPlatformAdmin default) - ql.rows.push({ id: 'ps_legacy', name: 'member_default', object_permissions: '{"x":{"allowRead":true}}' }); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(0); - expect(r.updated).toBe(0); - expect(r.skippedEnvAuthored).toBe(1); - expect(ql.rows[0].object_permissions).toBe('{"x":{"allowRead":true}}'); - expect(ql.rows[0].managed_by).toBeUndefined(); - }); - - it('refuses to write into a row owned by a DIFFERENT package', async () => { - const ql = makeQl([declaredSet({ _packageId: 'com.example.other' })]); - ql.rows.push({ - id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm', - object_permissions: '{}', - }); - const warns: any[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m, meta) => warns.push({ m, meta }) }, - }); - expect(r.skippedForeign).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.crm'); - // [#17516] Re-anchored from the old prose ('owned by another package') to - // the stable token the report now stamps. The substance this pin asserts is - // unchanged — the refusal is reported — but the token is what an operator - // greps and what the sibling doors key on, so prose drift can no longer - // quietly unpin it. The read-back half is asserted beside it: a counter - // with no record is what made this drop invisible. - expect(warns.some((w) => String(w.m).includes('permission_set_name_collision'))).toBe(true); - expect(r.collisions).toEqual([ - expect.objectContaining({ name: 'crm_sales_rep', declaredBy: 'com.example.other', ownedBy: 'com.example.crm' }), - ]); - }); - - it('skips a declared set with no resolvable owning package (warned, not seeded)', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - expect(r.seeded).toBe(0); - expect(ql.rows.length).toBe(0); - expect(warns.some((w) => w.includes('no owning package'))).toBe(true); - }); - - it('falls back to the spec-declared packageId (ADR-0086 D3) when registry provenance is absent', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined, packageId: 'com.example.declared' })]); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.declared'); - }); -}); - -// ADR-0086 P2 块1 — the publish-time materializer shares this helper. Here the -// packageId is supplied explicitly (the draft's binding), not read off the body. -describe('upsertPackagePermissionSet (ADR-0086 P2 — publish materialization)', () => { - const publishedBody = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true } }, - ...over, - }); - - it('materializes a published set into a package-managed row under the draft packageId', async () => { - const ql = makeQl(); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.seeded).toBe(1); - expect(ql.rows[0].managed_by).toBe('package'); - expect(ql.rows[0].package_id).toBe('com.example.crm'); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('re-publish of its OWN row updates in place (idempotent)', async () => { - const ql = makeQl(); - await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - const r2 = await upsertPackagePermissionSet( - ql, publishedBody({ objects: { crm_lead: { allowRead: true, allowEdit: true } } }), 'com.example.crm', - ); - expect(r2.updated).toBe(1); - expect(ql.rows.length).toBe(1); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('refuses to clobber an env-authored row of the same name (two-doors: env door owns it)', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_env', name: 'crm_sales_rep', managed_by: 'user', object_permissions: '{"kept":true}' }); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.skippedEnvAuthored).toBe(1); - expect(r.seeded + r.updated).toBe(0); - expect(ql.rows[0].object_permissions).toBe('{"kept":true}'); - }); - - it('refuses a name owned by a DIFFERENT package', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.other', object_permissions: '{}' }); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.skippedForeign).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.other'); - }); - - it('skips (does not materialize) when the publish carries no packageId', async () => { - const ql = makeQl(); - const warns: string[] = []; - const r = await upsertPackagePermissionSet(ql, publishedBody(), null, { info: () => {}, warn: (m) => warns.push(m) }); - expect(r.seeded).toBe(0); - expect(ql.rows.length).toBe(0); - expect(warns.some((w) => w.includes('no owning package'))).toBe(true); - }); -}); - -// The environment door (env-scope saves, the data-door write-through, boot -// reconciliation) moved to permission-set-projection.test.ts (ADR-0094). - -// ─────────────────────────────────────────────────────────────────────────── -// [#18091] The two refusals #17516 left behind in this seeder. Measured mute on -// the pre-fix tree with no logger injected — author-visible lines = 0 at both, -// while the already-repaired collision path in the same harness read 1. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Capture EVERY console channel — "author-visible output" is not - * channel-specific, and a pin watching only `warn` could be satisfied by a - * change that merely MOVED the silence. - */ -function captureAllConsole() { - const seen: string[] = []; - const spies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((m) => - vi.spyOn(console, m).mockImplementation((...args: unknown[]) => { - seen.push(`${m}: ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`); - }), - ); - return { seen, restore: () => spies.forEach((s) => s.mockRestore()) }; -} - -/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */ -function unreadableQl(declared: any[]) { - const ql = makeQl(declared); - (ql as any).find = async () => { throw new Error('sys_permission_set is unreachable'); }; - return ql; -} - -describe('[#18091] the two remaining permission-set refusals reach the author', () => { - it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, and names the RECORD as what is lost', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - // ── The reading this card moves: 0 → 1 ────────────────────────────────── - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]!.startsWith('warn: ')).toBe(true); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(cap.seen[0]).toContain('crm_sales_rep'); - expect(cap.seen[0]).toContain('has no owning package'); - // ── THIS site's consequence. ⛔ NOT the capability axis': the evaluator - // resolves declared sets through the metadata registry, so every grant - // keeps working and only the RECORD is missing. An author told merely - // "not materialized" goes looking for a denied user who does not exist. - expect(cap.seen[0]).toContain('keep working'); - expect(cap.seen[0]).toContain('Setup admin surface'); - expect(cap.seen[0]).toContain('ADR-0086 D3'); - - // ── ⛔ The refusal itself is UNCHANGED ─────────────────────────────────── - expect(r.seeded).toBe(0); - expect(ql.rows).toHaveLength(0); - }); - - it('UNOWNED DECLARATION reaches the author through the ADR-0086 P2 PUBLISH door too', async () => { - // ⚠️ The second door onto the same branch: the publish materializer upserts - // ONE set and passes no collector, so a fix that only lit the boot loop - // would leave this caller exactly as mute as before. - const ql = makeQl(); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await upsertPackagePermissionSet(ql, declaredSet({ _packageId: undefined }), null); - } finally { - cap.restore(); - } - - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r.seeded).toBe(0); - expect(ql.rows).toHaveLength(0); - }); - - it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => { - const ql = unreadableQl([declaredSet(), declaredSet({ name: 'crm_manager' })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(r.unreadable).toBe(2); - const line = cap.seen.find((l) => l.includes(PERMISSION_SET_ROWS_UNREADABLE)); - expect(line).toBeDefined(); - expect(line!.startsWith('warn: ')).toBe(true); - expect(line).toContain('2 of 2'); - // Silence here reads exactly like "everything was already in order", so the - // line has to say what did NOT happen — and that no grant is denied by it. - expect(line).toContain('neither seeded nor reconciled'); - expect(line).toContain('no grant is'); - // ⛔ Unchanged: an unreadable read writes nothing. - expect(ql.rows).toHaveLength(0); - }); - - it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => { - const cap = captureAllConsole(); - try { - await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined); - await bootstrapDeclaredPermissions(unreadableQl([declaredSet()]), undefined); - } finally { - cap.restore(); - } - - const unowned = cap.seen.find((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED)); - const unreadable = cap.seen.find((l) => l.includes(PERMISSION_SET_ROWS_UNREADABLE)); - expect(unowned).toBeDefined(); - expect(unreadable).toBeDefined(); - // Each names a phrase only its own site can produce. - expect(unowned).toContain('Setup admin surface'); - expect(unowned).not.toContain('could not be read'); - expect(unreadable).toContain('could not be read'); - expect(unreadable).not.toContain('ADR-0086 D3'); - }); - - it('an INJECTED logger takes both, and the console stays clean', async () => { - const warn = vi.fn(); - const cap = captureAllConsole(); - try { - await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined, { logger: { warn } }); - await bootstrapDeclaredPermissions(unreadableQl([declaredSet()]), undefined, { logger: { warn } }); - } finally { - cap.restore(); - } - - // ⚠️ The second pass also trips the batched existence oracle's own - // read-failure line — a different diagnostic in `seed-name-lookup.ts`, - // outside this card — so filter by this card's tokens rather than counting. - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED, PERMISSION_SET_ROWS_UNREADABLE]); - expect(cap.seen).toEqual([]); - }); - - it('CONTROL: a pass that really seeds and refuses nothing is SILENT on all five channels', async () => { - // ⛔ The discriminating half. Without it, a seeder that warned on every - // declaration would satisfy every assertion above. - const ql = makeQl([declaredSet()]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(r.seeded).toBe(1); - expect(cap.seen).toEqual([]); - }); - - it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => { - // The old `logger?.warn?.()` bought safety against a plain-JS embedder with - // silence; a bare `logger.warn()` would buy noise with a throw inside the - // seeding pass. The `typeof` guard in `reportThroughSink` buys neither. - const liar = { info: () => {} } as any; - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined, { logger: liar }); - } finally { - cap.restore(); - } - expect(r.seeded).toBe(0); - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - }); - - it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => { - const source = readFileSync(resolve(HERE, 'bootstrap-declared-permissions.ts'), 'utf8'); - // Positive control — the pin is reading the file it thinks it is. - expect(source).toContain('export async function bootstrapDeclaredPermissions'); - const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.(')); - for (const line of hits) { - expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true); - } - // ⚠️ The INFO channel keeps its outer `?.` deliberately — a healthy pass - // must stay silent on every console channel, per the CONTROL above. - expect(source).toContain("options.logger?.info?.("); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#18571] The unowned refusal's PROGRAMMATIC half. #18564 gave this branch an -// author-visible line; the outcome still came back with every counter at zero, -// so a caller that reads no log at all could not tell a pass that refused a -// declaration from a pass with nothing to do. The capability axis has counted -// the same refusal at the same ADR-0086 D3 boundary since #4967. -// ─────────────────────────────────────────────────────────────────────────── - -/** Silent sink — these tests assert the COUNTER, and #18091 owns the line. */ -const mute = () => ({ info: () => {}, warn: () => {} }); - -describe('[#18571] the unowned permission-set refusal moves a counter', () => { - it('BOOT-LOOP DOOR: an unowned declaration increments skippedUnowned', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(r.skippedUnowned).toBe(1); - // ⛔ The refusal itself is UNCHANGED — an unowned `managed_by:'package'` - // row is the ADR-0086 D3 ambiguity this branch exists to prevent. - expect(ql.rows).toHaveLength(0); - expect(r.seeded + r.updated + r.unchanged).toBe(0); - // ⛔ And it lands in its OWN counter, never borrowed from a sibling: an - // unowned declaration is not a foreign row and not an env-authored one. - expect(r.skippedForeign).toBe(0); - expect(r.skippedEnvAuthored).toBe(0); - expect(r.unreadable).toBe(0); - }); - - it('PUBLISH DOOR (ADR-0086 P2): the materializer increments it too', async () => { - // ⚠️ The second door onto the same branch, and the reason testing one is - // testing half: the publish materializer upserts ONE set and passes no - // collector, so a fix wired into the boot loop's aggregation alone would - // leave this caller's outcome exactly as silent as before. - const ql = makeQl(); - - const r = await upsertPackagePermissionSet(ql, declaredSet({ _packageId: undefined }), null, mute()); - - expect(r.skippedUnowned).toBe(1); - expect(ql.rows).toHaveLength(0); - expect(r.seeded).toBe(0); - }); - - it('CONTROL: a pass that refuses nothing leaves it at 0 through both doors', async () => { - // ⛔ The discriminating half. Without it, a counter incremented on every - // declaration would satisfy both assertions above. - const boot = await bootstrapDeclaredPermissions(makeQl([declaredSet()]), undefined, { logger: mute() }); - expect(boot).toMatchObject({ seeded: 1, skippedUnowned: 0 }); - - const publish = await upsertPackagePermissionSet(makeQl(), declaredSet(), 'com.example.crm', mute()); - expect(publish).toMatchObject({ seeded: 1, skippedUnowned: 0 }); - }); - - it('⭐ CONSERVATION: every named declaration lands in exactly one counter', async () => { - // Modelled on the capability axis' `materializedNames reconciles with the - // outcome counters`. The point is not the six numbers — it is that they - // SUM to the input, which is precisely what an uncounted refusal broke. - const body = (name: string, over: Record = {}) => - declaredSet({ name, _packageId: 'com.a', ...over }); - const sets = [ - body('crm_new'), // → seeded - body('crm_own', { label: 'Fresh' }), // → updated - body('crm_same'), // → unchanged - body('crm_env'), // → skippedEnvAuthored - body('crm_foreign'), // → skippedForeign - declaredSet({ name: 'crm_orphan', _packageId: undefined }), // → skippedUnowned - ]; - const ql = makeQl(sets); - // Its own row, stale — the stored label is not what the declaration says. - ql.rows.push({ - id: 'ps_own', name: 'crm_own', managed_by: 'package', package_id: 'com.a', - ...permissionSetRowFields(body('crm_own', { label: 'Stale' })), - }); - // Its own row, already converged — `recordDiffersFromBody` calls it equal, - // so no UPDATE is issued and the name lands in `unchanged`, not `updated`. - ql.rows.push({ - id: 'ps_same', name: 'crm_same', managed_by: 'package', package_id: 'com.a', - ...permissionSetRowFields(body('crm_same')), - }); - ql.rows.push({ id: 'ps_env', name: 'crm_env', managed_by: 'user', object_permissions: '{"kept":true}' }); - ql.rows.push({ id: 'ps_far', name: 'crm_foreign', managed_by: 'package', package_id: 'com.z', object_permissions: '{}' }); - - const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(out).toMatchObject({ - seeded: 1, updated: 1, unchanged: 1, - skippedEnvAuthored: 1, skippedForeign: 1, skippedUnowned: 1, - unreadable: 0, - }); - // ⚠️ The sum is taken over EVERY counter the outcome declares, so a future - // counter added without a home here fails this pin instead of hiding in - // a hand-picked subset — the failure mode `skippedUnowned` itself was. - const counted = out.seeded + out.updated + out.unchanged - + out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable; - expect(counted).toBe(sets.length); - // …and the refusal that wrote nothing really wrote nothing: five inputs - // reached a row, the sixth did not. - expect(ql.rows.map((r) => r.name).sort()).toEqual( - ['crm_env', 'crm_foreign', 'crm_new', 'crm_own', 'crm_same'], - ); - expect(ql.rows.some((r) => r.name === 'crm_orphan')).toBe(false); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#21669] An ENVIRONMENT-owned set in the registry walk is not an unowned -// declaration. The engine registry the boot loop walks also holds every -// env-wide `sys_metadata` `permission` row `loadMetaFromDb` hydrates — a set -// made by Setup's Clone action among them — and such an item carries no -// package id because it has none. The loop judged "unowned" before it read -// the row, so a set whose `managed_by:'admin'` row Setup lists and edits was -// reported, on every boot, as one Setup "cannot see". -// -// The item below is the shape measured on a real showcase cold boot after a -// Clone (the boot-level pin is -// `packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts`): -// no `_packageId`, no `packageId`, `_provenance: 'org'`. -// ─────────────────────────────────────────────────────────────────────────── - -const CLONE_NAME = 'crm_sales_rep_local'; - -/** The registry item a hydrated clone is — measured, not invented. */ -const clonedItem = () => ({ - name: CLONE_NAME, - label: 'Sales Rep (local)', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - fields: {}, - systemPermissions: [], - rowLevelSecurity: [], - tabPermissions: {}, - _provenance: 'org', -}); - -/** The row the Clone action leaves: the environment's own set. */ -const clonedRow = (over: Record = {}) => ({ - id: 'ps_clone', name: CLONE_NAME, managed_by: 'admin', package_id: null, active: true, - object_permissions: '{"crm_lead":{"allowRead":true,"allowCreate":true}}', - ...over, -}); - -/** The unowned refusal's first sentence — the text an operator reads, unchanged by this card. */ -const unownedFirstSentence = (name: string) => - `declared permission set "${name}" has no owning package — not materialized.`; - -describe('[#21669] an environment-owned set is told apart from an unowned declaration', () => { - it('a CLONED set (row managed_by admin) boots with no warning on any channel, and its row is untouched', async () => { - const ql = makeQl([clonedItem()]); - ql.rows.push(clonedRow()); - const before = JSON.stringify(ql.rows); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(cap.seen.filter((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED))).toEqual([]); - expect(cap.seen).toEqual([]); - // Where a package declaration over an environment row already lands — - // never clobbered, and counted, so CONSERVATION still holds. - expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0, updated: 0, unchanged: 0 }); - expect(JSON.stringify(ql.rows)).toBe(before); - }); - - it('the same, on a per-organization pass: the environment door\'s organization-less row is the environment\'s', async () => { - // `permission-set-projection.ts` (the ENVIRONMENT door) is deliberately not - // per-organization, so a clone's row carries no organization_id and a - // walled pass sees it only beside its own rows. - const ql = makeQl([clonedItem()]); - ql.rows.push(clonedRow()); - - const warn = vi.fn(); - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [], - }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).not.toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0 }); - expect(ql.rows).toHaveLength(1); - }); - - it('⛔ a TRULY unowned declaration — no package id, no row — still warns, with the same code and text', async () => { - const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(`[${PERMISSION_SET_DECLARATION_UNOWNED}]`); - expect(cap.seen[0]).toContain(unownedFirstSentence('crm_orphan')); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - expect(ql.rows).toHaveLength(0); - }); - - it('⛔ the same on a per-organization pass with no row at all', async () => { - const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]); - const warn = vi.fn(); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [], - }); - - const unowned = warn.mock.calls.filter((c) => (c[1] as any)?.event === PERMISSION_SET_DECLARATION_UNOWNED); - expect(unowned).toHaveLength(1); - expect(String(unowned[0]![0])).toContain(unownedFirstSentence('crm_orphan')); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⛔ a row the PACKAGE door owns is not the environment\'s: an unowned item over it still warns', async () => { - // The guard asks who owns the ROW. A `managed_by:'package'` row under the - // name is a package's record, so it proves nothing about an item that - // carries no owner of its own. - const ql = makeQl([declaredSet({ _packageId: undefined })]); - ql.rows.push({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' }); - - const warn = vi.fn(); - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED]); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⛔ a read that cannot answer proves nothing: the unowned refusal stands, exactly as before', async () => { - const ql = unreadableQl([clonedItem()]); - const warn = vi.fn(); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⭐ CONSERVATION, with a clone in the walk: every named item lands in exactly one counter', async () => { - const owned = declaredSet({ name: 'crm_new', _packageId: 'com.a' }); - const orphan = declaredSet({ name: 'crm_orphan', _packageId: undefined }); - const ql = makeQl([owned, clonedItem(), orphan]); - ql.rows.push(clonedRow()); - - const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(out).toMatchObject({ seeded: 1, skippedEnvAuthored: 1, skippedUnowned: 1, unreadable: 0 }); - const counted = out.seeded + out.updated + out.unchanged - + out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable; - expect(counted).toBe(3); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts deleted file mode 100644 index da70bca61e6..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredPermissions — seed stack-declared `permissions` into - * `sys_permission_set` (ADR-0086 D5; the exact sibling of - * `bootstrapDeclaredPositions`). - * - * `stack.permissions` has always been declarable and runtime-ENFORCED (the - * evaluator resolves declared sets through the metadata registry), but it was - * never materialized as `sys_permission_set` records — the ADR-0078 - * inert-metadata smell: the admin surface (which reads the table) can't see a - * package's sets, uninstall is undefined, and no provenance axis exists. This - * seeder closes that gap: - * - * - each declared set is upserted by `(name, organization_id)` with `managed_by: 'package'` and - * `package_id` = the registering package (`_packageId` stamped by the - * SchemaRegistry / ADR-0010 `applyProtection`, with the spec-level - * `packageId` (ADR-0086 D3) as the author-declared fallback); - * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns - * (`managed_by:'package'`, same `package_id`) is re-seeded on every boot so - * the record always reflects the shipped declaration (version bumps - * included). Rows owned by a DIFFERENT package are skipped loudly; - * - env-authored rows are NEVER clobbered: `managed_by` of - * `platform`/`user` — or absent (legacy/pre-provenance rows, including the - * platform defaults inserted by `bootstrapPlatformAdmin`) — is left alone. - * - * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform - * defaults keep their existing insert-once shape) and alongside - * `bootstrapDeclaredPositions`. - * - * The ENVIRONMENT door (env-scope metadata saves, the Setup data-door - * write-through, boot reconciliation) lives in `permission-set-projection.ts` - * (ADR-0094) — this module is the PACKAGE door only. Both project through the - * shared {@link permissionSetRowFields} row shape so they can never hydrate - * differently. - * - * [#10103] The PACKAGE door runs ONE PASS PER ORGANIZATION when a wall is in - * force: `sys_permission_set` spells its name index `unique: 'organization'`, - * and an organization-less row was measured unreadable by every principal on a - * walled deployment — Layer 0's strict `organization_id = :tenant` AND-composes - * over the driver's compatibility arm and the conjunction is the strict equality - * alone. `single` posture keeps exactly one organization-less pass. The - * ENVIRONMENT door is deliberately NOT converted: its organization-less residue - * (and `bootstrapPlatformAdmin`'s platform defaults) stays outside this change, - * unreaped and warned about loudly. See `per-organization-catalog.ts`. - */ - -import { - genId, - permissionSetRowFields, - recordDiffersFromBody, - tryInsert, - tryUpdate, - type PermissionSeedOutcome, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - buildExistingByName, - type ExistingByNameIndex, - type ExistingLookupResult, -} from './seed-name-lookup.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { - resolveOwnOrganizationRow, - seedCtx, - warnOrganizationLessRows, -} from './organization-scope.js'; -import { - permissionSetNameCollisionDiagnostic, - permissionSetNameIsForeign, - reportPermissionSetNameCollisions, - type PermissionSetNameCollisionDiagnostic, -} from './permission-set-name-collision.js'; -// [#18091] This seeder's two remaining refusals, each with its own wording, -// token and record, over the one shared delivery rule. -import { - permissionSetDeclarationUnownedDiagnostic, - reportPermissionSetDeclarationUnowned, - reportPermissionSetRowsUnreadable, -} from './seed-refusal-diagnostics.js'; - -export type { PermissionSeedOutcome } from './permission-set-projection.js'; - -/** - * The per-name existence read used when no batched oracle was supplied (the - * ADR-0086 P2 publish materializer, which upserts exactly one set). Reports the - * same three outcomes the batched oracle does — a failed read must not read as - * "absent" on this path either. - */ -async function defaultLookup(ql: any, name: string, organizationId?: string): Promise { - let rows: any; - try { - // Limit 5, not 1, when scoped: a scoped read returns organization-less rows - // alongside this organization's own, and one row would be whichever the - // driver ordered first (#10103). - rows = await ql.find( - 'sys_permission_set', - { where: { name }, limit: organizationId ? 5 : 1 }, - { context: seedCtx(organizationId) }, - ); - } catch { - return { status: 'unknown' }; - } - // Bare array, driven against a real engine over a real `SqlDriver` — not - // inferred from `IDataEngine.find`'s declared `Promise`, which is not - // proof (this repo has a `find()` that resolves an envelope instead). The - // `{ records }` limb that stood here was dead; `engine-find-bare-array.pin.test.ts` - // pins this seam. A non-array is still `unknown` — never "no such row". - const list = Array.isArray(rows) ? rows : null; - if (list === null) return { status: 'unknown' }; - // [#10103] This organization's own row answers; an organization-less leftover - // is reported beside `absent` and never returned as `present`. One spelling of - // that question for the whole catalog — see `per-organization-catalog.ts`. - const { own, organizationLessResidue } = resolveOwnOrganizationRow(list, organizationId); - if (own) return { status: 'present', row: own }; - return organizationLessResidue - ? { status: 'absent', organizationLessResidue } - : { status: 'absent' }; -} - -interface SeedOptions { - logger?: ProjectionLogger; - /** - * Seed THIS organization's copies. Omitted = the `single`-posture pass, the - * one place an organization-less catalog row is the correct shape. - */ - organizationId?: string; - /** - * [#11532] Names that `bootstrapPlatformAdmin` still seeds organization-less - * on every boot — the PLATFORM BUCKET. Supplied by the caller because the - * caller is the one holding that array (`security-plugin.ts` hands the same - * `bootstrapPermissionSets` to the platform bootstrap and to the manifest), - * so the list is exact rather than inferred from a row's provenance. - * - * An organization-less row for one of these names is NOT a pre-fix leftover - * and the pre-fix remedy is a loop for it: re-initializing the deployment - * mints it again on the next boot. See {@link warnOrganizationLessRows}. - * - * Omitted falls back to the SHIPPED `defaultPermissionSets` — which is what - * `bootstrapPlatformAdmin` seeds unless the host passed - * `SecurityPluginOptions.defaultPermissionSets`. So the classification is - * right for every shipped composition even if this option is never threaded, - * and the option exists for the one case the fallback cannot know about: a - * host that overrode the array. Pass `[]` to state that this caller's - * deployment has no organization-less writer at all. - */ - platformBucketNames?: readonly string[]; -} - -/** - * Read declared metadata items of a type. The engine's SchemaRegistry - * (populated by `manifest.register` from the stack's `permissions` array, - * items provenance-stamped with `_packageId`) is the reliable source in every - * boot path; the metadata-service facade only surfaces these once the - * compiled-artifact loader runs (serve.ts). - * - * [#8378] The registered item IS the authoring document — there is no - * `{ name, content }` envelope to unwrap, so the `i?.content ?? i` this read - * used to carry is gone. Same measurement #7519 made for `MetadataFacade`, - * re-taken at this seam: `registerMetadataCollections` (objectql `engine.ts`) - * registers each stack-collection element as-is, and `loadMetaFromDb` registers - * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never the - * `sys_metadata` row. Nothing in the tree produces the envelope. - * - * Removal is a FIX, not a tidy-up. The types read through here (`permission`, - * `capability`, `object`) all reject `content` as an unrecognized key, so - * whenever the key did appear the unwrap replaced a whole authoring document - * with one of its values — and `''`, falsy but non-nullish, passed `??` and - * then died at the `filter(Boolean)` below, dropping the item with no - * diagnostic at all. - */ -export function readDeclared(engine: any, type: string): any[] { - try { - const reg = engine?.registry; - if (reg?.listItems) { - return (reg.listItems(type) ?? []).filter(Boolean); - } - } catch { /* fall through */ } - return []; -} - -/** - * ADR-0086 two-doors — is this `sys_permission_set` row ENVIRONMENT-owned? - * - * The one spelling of the fact this seeder already acted on: a row the PACKAGE - * door does not own — `managed_by` `admin`/`platform`, the legacy `user`, or - * absent (pre-provenance rows, including `bootstrapPlatformAdmin`'s platform - * defaults) — is environment-authored config that package materialization - * never clobbers. {@link upsertPackagePermissionSet}'s last branch and the boot - * loop's unowned-vs-environment split both ask THIS predicate, so the two can - * never disagree about which rows are the environment's. - */ -function isEnvironmentOwnedRow(row: any): boolean { - return !!row && typeof row === 'object' && row.managed_by !== 'package'; -} - -/** - * [#21669] Does the environment already own a row under this name? - * - * The engine registry the boot loop walks holds MORE than package - * declarations: `loadMetaFromDb` hydrates every env-wide `sys_metadata` - * `permission` row into the same collection, and a set an environment - * authored itself — the Setup Clone action, a Setup "New", a metadata-door - * save — carries no package id there, because none exists. Read as a - * declaration, such an item is "unowned"; it is not a declaration at all. Its - * row stands, the environment door owns it, and Setup lists it. - * - * Answered from the batched oracle the loop already holds — no extra read. - * The row is this pass's own, or — on a per-organization pass — the - * organization-less row the ENVIRONMENT door writes (`permission-set- - * projection.ts` is deliberately not per-organization; see this file's - * header). `unknown` is NOT an answer: a read that failed cannot prove the - * environment owns the name, so the caller falls through to the unowned - * refusal exactly as before. - */ -async function environmentOwnsName(existingByName: ExistingByNameIndex, name: string): Promise { - const lookup = await existingByName.get(name); - const row = lookup.status === 'present' - ? lookup.row - : lookup.status === 'absent' ? lookup.organizationLessResidue : undefined; - return isEnvironmentOwnedRow(row); -} - -/** - * Upsert ONE declared/published PermissionSet body into `sys_permission_set` - * under the owning `packageId`, applying the ADR-0086 provenance rules - * (own-row re-seed, foreign-package refuse, env-authored never clobbered). - * Shared by the boot seeder (every declared set) and the publish-time - * materializer (ADR-0086 P2 — a package-door set promoted from a draft). Returns - * a one-hot outcome so callers can aggregate. - */ -export async function upsertPackagePermissionSet( - ql: any, - ps: any, - packageId: string | null | undefined, - logger?: SeedOptions['logger'], - opts?: { - /** - * Existence oracle to consult instead of this function's own per-name - * `SELECT` (#10946). The boot loop passes ONE batched read covering every - * declared name; the publish materializer, which upserts a single set, - * passes nothing and keeps the per-name read. - */ - existingByName?: ExistingByNameIndex; - /** - * Seed THIS organization's copy (#10103). Omitted = the pre-existing - * organization-less behaviour, which the `single`-posture pass and the - * ADR-0086 P2 publish materializer both want. - */ - organizationId?: string; - /** Collects names whose pre-fix organization-less row is still standing. */ - residue?: string[]; - /** - * Collects writes the database REFUSED, so the pass can report them once - * instead of returning a zero that reads as "nothing to do". Passed by the - * boot catalog loop; the ADR-0086 P2 publish materializer passes nothing - * and keeps its own honest outcome (`success: false` with a reason when it - * materialized nothing). - */ - refusals?: SeedWriteRefusals; - /** - * [#17516] Collects set-name collisions so the pass reports them ONCE - * instead of a line per dropped set. Passed by the boot catalog loop; the - * ADR-0086 P2 publish materializer passes nothing and the refusal is - * reported at the branch instead — ⛔ never dropped, which is the whole - * point of this card. - */ - collisions?: PermissionSetNameCollisionDiagnostic[]; - }, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ps?.name) return out; - // A `managed_by:'package'` row without a `package_id` would make uninstall - // undefined again — the exact ambiguity ADR-0086 D3 exists to remove — so a - // set with no resolvable owner is skipped rather than materialized unowned. - if (!packageId) { - out.skippedUnowned += 1; - // [#18091] ⛔ The refusal is unchanged — an unowned row would re-create the - // ADR-0086 D3 ambiguity. What changed is that it arrives. Measured on the - // pre-fix tree with no logger, through BOTH doors that reach this branch - // (the boot loop and the ADR-0086 P2 publish materializer, which passes no - // collector): author-visible console lines = 0. - // - // [#18571] And a counter, because the LINE is not the programmatic half: a - // caller that reads no log at all — a boot report, a test — asked the - // outcome and got six zeros back, indistinguishable from a pass with - // nothing to do. Both doors above increment it; the boot loop forwards it. - // - // Its own wording, because the consequence is not the capability axis': - // the declared set stays runtime-enforced and only the RECORD is missing. - reportPermissionSetDeclarationUnowned(logger, permissionSetDeclarationUnownedDiagnostic({ - name: String(ps.name), - ...(opts?.organizationId ? { organizationId: opts.organizationId } : {}), - })); - return out; - } - - // ⛔ Three outcomes, not two (#10946 / #3807). `unknown` — the read FAILED — - // is not "no such row": inserting on it would re-create a set that already - // exists, and on a batched read one failure speaks for every declared name at - // once. Declining is the answer; the caller's warn reports it. - const organizationId = opts?.organizationId; - const lookup = opts?.existingByName - ? await opts.existingByName.get(String(ps.name)) - : await defaultLookup(ql, String(ps.name), organizationId); - if (lookup.status === 'unknown') { - out.unreadable += 1; - return out; - } - // [#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 && ps.name) { - opts?.residue?.push(String(ps.name)); - } - const existing = lookup.status === 'present' ? lookup.row : undefined; - if (!existing?.id) { - const row = { - id: genId('ps'), - name: ps.name, - ...permissionSetRowFields(ps), - active: true, - package_id: packageId, - managed_by: 'package', - }; - const created = await tryInsert(ql, 'sys_permission_set', row, organizationId, opts?.refusals); - if (created) { - out.seeded += 1; - // A batched oracle is a snapshot taken before the loop — tell it about - // the row we just made, so a name declared twice in one batch still - // reaches the collision branch below instead of a second insert. - opts?.existingByName?.remember(String(ps.name), row); - } - return out; - } - - if (!isEnvironmentOwnedRow(existing)) { - if (!permissionSetNameIsForeign(existing.package_id, packageId)) { - // Our own row — re-seed so the record always reflects the shipped/published - // declaration (idempotent; covers version bumps without bookkeeping). - // - // [#10946] "Idempotent" was implemented as "write the same columns every - // time", which on a remote libsql/Turso database is two HTTP round trips - // per set on every boot to change nothing. `recordDiffersFromBody` is the - // SAME comparison the ADR-0094 boot reconciler already trusts to decide - // whether a record drifted, over exactly the columns - // `permissionSetRowFields` writes — so a row it calls equal is a row this - // UPDATE could not have changed. - // - // ⚠️ The skip is on EQUALITY, never on "we have seen this name". A row - // whose stored value differs — a version bump, a hand-edit, a partially - // applied write — still gets its UPDATE, because dropping that leg would - // turn the loop into a no-op that reconciles nothing while showing a - // beautiful round-trip curve. - if (!recordDiffersFromBody(existing, ps)) { - out.unchanged += 1; - } else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId, opts?.refusals)) { - out.updated += 1; - } - } else { - // [#17516] The SKIP is unchanged and correct — ADR-0086 D4: a package - // never writes into a foreign record. What changed is that it is no - // longer invisible. - // - // ⚠️ The premise this branch used to state — "Package-namespaced object - // api names make set-name collisions a packaging bug, not a merge case" - // — is FALSIFIED by ADR-0130 D1, which lets N packages co-own one - // namespace (the ADR records it under "What was NOT decided"). A - // collision is therefore a legal configuration that gets MORE common as - // co-ownership lands, not a packaging error that should never happen. So - // the author reading it is the normal case, not the pathological one. - // - // ⛔ And the old line did not refuse loudly, whatever it claimed: - // `logger?.warn?.(…)` is optionally chained TWICE, so a caller passing no - // logger produced NO OUTPUT AT ALL and an entire declared permission set - // disappeared with one counter moved. The report now goes through - // `reportPermissionSetNameCollisions`, which prints with no sink injected - // (#10556: silent-by-declaration is rejected), and the diagnostic RECORD - // travels back on the outcome so a caller that reads no log at all — a - // boot report, a test — can still ask what happened. - out.skippedForeign += 1; - const diagnostic = permissionSetNameCollisionDiagnostic({ - name: String(ps.name), - declaredBy: packageId, - ownedBy: typeof existing.package_id === 'string' ? existing.package_id : null, - ...(organizationId ? { organizationId } : {}), - }); - out.collisions = [diagnostic]; - // The boot loop collects and reports ONCE per pass. The ADR-0086 P2 - // publish materializer upserts a single set and passes no collector, so - // it reports here — it has no pass to summarise, and inheriting the old - // silence is the one outcome this card forbids. - if (opts?.collisions) opts.collisions.push(diagnostic); - else reportPermissionSetNameCollisions(logger, [diagnostic], organizationId); - } - return out; - } - - // {@link isEnvironmentOwnedRow}: `admin`/`platform`/`user` — or absent - // (legacy rows, incl. bootstrapPlatformAdmin defaults): env-authored config. - // Never clobbered by package materialization. - out.skippedEnvAuthored += 1; - return out; -} - -/** - * [#11532] The names the SHIPPED `bootstrapPlatformAdmin` seeds - * organization-less on every boot. Computed once from the same declaration the - * platform bootstrap iterates, so the two cannot drift within a release. - */ -const SHIPPED_PLATFORM_BUCKET_NAMES: readonly string[] = defaultPermissionSets - .map((ps) => ps.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - -export async function bootstrapDeclaredPermissions( - ql: any, - metadataService: any, - options: SeedOptions = {}, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; - - let sets: any[] = readDeclared(ql, 'permission'); - if (sets.length === 0) { - try { - const listed = metadataService?.list?.('permission'); - sets = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { sets = []; } - } - if (!Array.isArray(sets) || sets.length === 0) return out; - - // [#10946] ONE existence read for the whole declaration, before the loop — - // the set of names is known in full here. See `seed-name-lookup.ts` for why - // a read that cannot ANSWER must not be read as "none of them exist". - const organizationId = options.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - sets.map((ps) => ps?.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[] = []; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY declared permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#17516] Declared sets dropped because another package owns the name. - const collisions: PermissionSetNameCollisionDiagnostic[] = []; - - for (const ps of sets) { - if (!ps?.name) continue; - // Registry provenance first (ADR-0010 `_packageId`), author-declared - // spec `packageId` (ADR-0086 D3) as fallback. - const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined; - // [#21669] Tell an ENVIRONMENT-owned set apart from a package declaration - // BEFORE judging ownership. A registry item with no package id whose row - // the environment already owns (a Setup clone: `managed_by:'admin'`, no - // `package_id`) is not an unowned declaration — it is not a declaration — - // and the `permission_set_declaration_unowned` warning told the operator, - // on every boot and every `metadata:reloaded`, that Setup could not see a - // set Setup lists and edits. It lands where every package declaration - // over an environment row lands: `skippedEnvAuthored`, never clobbered. - // ⛔ A declaration with no owner AND no environment row still reaches the - // unowned refusal below, unchanged — that warning is true. - if (!packageId && await environmentOwnsName(existingByName, String(ps.name))) { - out.skippedEnvAuthored += 1; - continue; - } - const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals, collisions }); - out.seeded += r.seeded; - out.updated += r.updated; - out.unchanged += r.unchanged; - out.unreadable += r.unreadable; - out.skippedEnvAuthored += r.skippedEnvAuthored; - out.skippedForeign += r.skippedForeign; - out.skippedUnowned += r.skippedUnowned; - } - - if (organizationId) { - warnOrganizationLessRows( - options.logger, - 'sys_permission_set', - residue, - organizationId, - options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES, - ); - } - // Before the counts, so an operator reads WHY the count is zero beside it. - reportSeedWriteRefusals(options.logger, refusals, organizationId); - // [#17516] Said once per pass, and said even when no logger was injected — - // the whole defect was that this refusal reached nobody. The records go back - // on the outcome too, for a caller that reads no log at all. - reportPermissionSetNameCollisions(options.logger, collisions, organizationId); - if (collisions.length > 0) out.collisions = collisions; - if (out.unreadable > 0) { - // Said once, with the count: these sets were neither seeded nor reconciled - // because the record could not be READ. Silence here would read exactly - // like "everything was already in order". - // [#18091] …and said even when no logger was injected: silence here reads - // exactly like "everything was already in order", which is the whole defect. - reportPermissionSetRowsUnreadable(options.logger, { - unreadable: out.unreadable, - total: sets.length, - ...(organizationId ? { organizationId } : {}), - }); - } - - options.logger?.info?.('[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)', { - ...out, total: sets.length, ...(organizationId ? { organization: organizationId } : {}), - }); - return out; -} 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 deleted file mode 100644 index 30d131fc92b..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#10946] The identity boot seeders cost O(1) database round trips on a - * steady-state rebuild — and still reconcile. - * - * ## What is measured here, and what is NOT - * - * The defect this file pins is a **COUNT**, not a latency: every declared - * permission set and every declared position cost exactly 4 sequential database - * round trips on every kernel boot (2 × existence `SELECT`, 1 × `UPDATE`, - * 1 × `SELECT`), of which the `UPDATE` fired even when nothing had changed. - * - * On a local file database that loop is invisible; on a remote libsql/Turso - * database — every hosted environment — each leg is its own sequential HTTP - * request. - * - * A count is measurable without the hosted rig, so that is what these tests - * measure: every `find` / `insert` / `update` the seeder issues against the - * ObjectQL facade is one round trip, counted by {@link makeCountingQl}. - * - * ⚠️ The card's LATENCY figure (the whole `bootstrap` step growing 171.7 ms per - * ms of injected RTT, R² = 0.998) is **inherited from the hosted rig in - * `objectstack-ai/cloud`, not reproduced here** — nothing in this file measures - * wall time, and a test that did would measure the machine it ran on. - * - * ## Why the assertions are shaped the way they are - * - * A round-trip suite alone is a trap: an implementation that simply stopped - * writing would produce a perfect curve and silently stop reconciling — the - * loops would keep their shape and lose their purpose. So the counting tests - * are paired, one for one, with reconciliation tests over the same fixtures: - * a drifted row still gets its `UPDATE`, an absent name is still created, and a - * read that FAILED is never mistaken for a read that answered "none". - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.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. - */ -function catalogRegistry(type: string, items: () => any[]) { - return { - listItems: (t: string) => (t === type ? [...items()] : []), - getItem: (t: string, name: string) => (t === type ? items().find((i) => i?.name === name) : undefined), - isPackageDisabled: () => false, - }; -} - -interface CountingQl { - rows: any[]; - calls: { find: number; insert: number; update: number }; - /** Every round trip in issue order — `find`/`insert`/`update`. */ - log: string[]; - /** Payloads of the `where` clauses the seeder issued, for shape assertions. */ - wheres: any[]; - roundTrips(): number; - reset(): void; - registry: ReturnType; - find(object: string, q: any, opts?: any): Promise; - insert(object: string, data: any, opts?: any): Promise; - update(object: string, data: any, options?: any): Promise; -} - -/** - * An in-memory ObjectQL facade that COUNTS calls. Supports the `$in` membership - * operator, because the real engine does (`security-plugin.ts` already reads - * `sys_permission_set` with `{ name: { $in: names } }`) — a double that refused - * it would be pinning the double's limits, not the seeder's behaviour. - */ -function makeCountingQl( - object: string, - metadataType: string, - declared: any[], - behaviour: { findThrows?: boolean; findReturnsNonArray?: boolean } = {}, -): CountingQl { - const rows: any[] = []; - const matches = (row: any, where: any): boolean => - Object.entries(where ?? {}).every(([key, cond]) => { - // REFUSE the combinators this double does not implement rather than - // reading `$and`/`$or` as a column name — a matcher that silently treats - // a combinator as a field is how a fake quietly answers a question the - // real engine would have answered differently. - if (key.startsWith('$')) { - throw new Error(`counting driver: unsupported combinator ${key}`); - } - // [commit c33f18592] A `null` comparand is IS NULL, not `=== null`. `driver-sql` - // compiles `{ field: null }` to `IS NULL`; a column never written is NULL - // in the database and `undefined` in this double, and strict equality - // matches neither. Inert for every describe above — none of them issues a - // null comparand — and required by the curated capability read, which is - // predicated on `organization_id: null`. - if (cond === null) return row[key] == null; - if (cond && typeof cond === 'object' && !Array.isArray(cond)) { - const inList = (cond as any).$in; - if (Array.isArray(inList)) return inList.includes(row[key]); - throw new Error(`counting driver: unsupported operator ${Object.keys(cond).join(',')}`); - } - return row[key] === cond; - }); - - const ql: CountingQl = { - rows, - calls: { find: 0, insert: 0, update: 0 }, - log: [], - wheres: [], - roundTrips() { return this.calls.find + this.calls.insert + this.calls.update; }, - reset() { this.calls = { find: 0, insert: 0, update: 0 }; this.log = []; this.wheres = []; }, - registry: catalogRegistry(metadataType, () => declared), - async find(obj: string, q: any) { - if (obj !== object) return []; - ql.calls.find += 1; - ql.log.push('find'); - ql.wheres.push(q?.where); - if (behaviour.findThrows) throw new Error('counting driver: read unavailable'); - if (behaviour.findReturnsNonArray) return undefined as any; - return rows.filter((r) => matches(r, q?.where)); - }, - async insert(obj: string, data: any) { - if (obj !== object) return null; - ql.calls.insert += 1; - ql.log.push('insert'); - rows.push({ ...data }); - return { id: data.id }; - }, - // Routed through the real dispatch predicate: a fake looser than - // ObjectQL.update would let the seeder drift to a call shape the engine - // refuses while this suite stayed green. - async update(obj: string, data: any, options?: any) { - if (obj !== object) return; - ql.calls.update += 1; - ql.log.push('update'); - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? rows.filter((r) => r.id === dispatch.id) - : rows.filter((r) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; - return ql; -} - -/** - * `objects` is typed rather than inferred: the upgrade fixtures below widen a - * grant (`{ allowRead }` -> `{ allowRead, allowEdit }`) to simulate a package - * version bump, which an inferred literal type rejects. - */ -interface DeclaredSet { - name: string; - label: string; - objects: Record>; - systemPermissions: string[]; - _packageId: string; -} - -const declaredSets = (n: number): DeclaredSet[] => - Array.from({ length: n }, (_, i) => ({ - name: `pkg_set_${i}`, - label: `Set ${i}`, - objects: { crm_lead: { allowRead: true } }, - systemPermissions: [`crm.use.${i}`], - _packageId: 'com.example.crm', - })); - -const permissionQl = (declared: any[], behaviour = {}) => - makeCountingQl('sys_permission_set', 'permission', 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 () => { - const measure = async (n: number) => { - const ql = permissionQl(declaredSets(n)); - await bootstrapDeclaredPermissions(ql, undefined); // first boot: seeds - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); // REBUILD - 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)]; - // The count is asserted, never the wall time. - expect([n1, n5, n20, n40]).toEqual([1, 1, 1, 1]); - }); - - it('issues ONE batched `$in` existence read for the whole declaration', async () => { - const ql = permissionQl(declaredSets(12)); - await bootstrapDeclaredPermissions(ql, undefined); - ql.reset(); - await bootstrapDeclaredPermissions(ql, undefined); - expect(ql.calls.find).toBe(1); - expect(ql.wheres[0]).toEqual({ name: { $in: declaredSets(12).map((s) => s.name) } }); - }); - - it('first boot costs one batched read plus one INSERT per genuinely new set', async () => { - const ql = permissionQl(declaredSets(10)); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(10); - expect(ql.calls.find).toBe(1); - expect(ql.calls.insert).toBe(10); - expect(ql.calls.update).toBe(0); - expect(ql.rows).toHaveLength(10); - }); -}); - -/** - * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write - * would pass every count above while reconciling nothing at all. - */ -describe('#10946 — drift STILL reconciles', () => { - it('a permission-set row whose stored grants differ still gets its UPDATE', async () => { - const ql = permissionQl(declaredSets(20)); - await bootstrapDeclaredPermissions(ql, undefined); - - // The package ships a changed declaration for exactly ONE of the 20. - const upgraded = declaredSets(20); - upgraded[7] = { ...upgraded[7], objects: { crm_lead: { allowRead: true, allowEdit: true } } }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? upgraded : []) }; - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - 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_set_7'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('a permission-set row a hand-edit drifted is healed back to the declaration', async () => { - const ql = permissionQl(declaredSets(3)); - await bootstrapDeclaredPermissions(ql, undefined); - // Someone wrote straight at the row. - ql.rows[1].object_permissions = JSON.stringify({ crm_lead: { allowDelete: true } }); - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.updated).toBe(1); - expect(JSON.parse(ql.rows[1].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); -}); - -describe('#10946 — a genuinely NEW declaration is still created', () => { - it('the batched read does not turn "absent" into "present" (permission sets)', async () => { - const ql = permissionQl(declaredSets(5)); - await bootstrapDeclaredPermissions(ql, undefined); - - const grown = [...declaredSets(5), { - name: 'pkg_set_new', label: 'New', objects: {}, _packageId: 'com.example.crm', - }]; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? grown : []) }; - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - expect(r.unchanged).toBe(5); - expect(ql.rows.map((x) => x.name)).toContain('pkg_set_new'); - // one batched read + one insert — the other five cost nothing at all - expect(ql.roundTrips()).toBe(2); - }); -}); - -/** - * ⛔ #3807's conflation class, at the seam a batched read newly exposes. The - * per-item shape was accidentally immune: a failed read fell through to an - * insert that failed too, for that ONE item. A batched read that swallowed its - * failure into `[]` would speak for the WHOLE set — every boot would conclude - * nothing is seeded and try to re-create everything. - * - * The judgement is "did the driver return a result set", never "is the array - * empty": an empty array is the answer "none of these names exist", and the - * first-boot tests above depend on that answer being trusted. - */ -describe('#10946 — a read that CANNOT ANSWER is not the answer "none exist"', () => { - it('a throwing read does NOT re-create rows that are already seeded (permission sets)', async () => { - const ql = permissionQl(declaredSets(4)); - await bootstrapDeclaredPermissions(ql, undefined); - expect(ql.rows).toHaveLength(4); - - // Every read now fails — the batched one and the per-item fallback alike. - const broken = permissionQl(declaredSets(4), { findThrows: true }); - broken.rows.push(...ql.rows.map((r) => ({ ...r }))); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(broken, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - - expect(r.seeded).toBe(0); - expect(r.unreadable).toBe(4); - expect(broken.calls.insert).toBe(0); // ⛔ no blind insert - expect(broken.rows).toHaveLength(4); // ⛔ nothing re-created - expect(warns.some((w) => w.includes('batched seed existence read failed'))).toBe(true); - expect(warns.some((w) => w.includes('could not be read'))).toBe(true); - }); - - it('a read returning a non-result (undefined) is not read as "none exist"', async () => { - const ql = permissionQl(declaredSets(4), { findReturnsNonArray: true }); - ql.rows.push(...declaredSets(4).map((s, i) => ({ - id: `ps_${i}`, name: s.name, managed_by: 'package', package_id: 'com.example.crm', - label: s.label, description: null, - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', - row_level_security: '[]', tab_permissions: '{}', admin_scope: null, - }))); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(0); - expect(r.unreadable).toBe(4); - expect(ql.calls.insert).toBe(0); - expect(ql.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); - expect(r.seeded).toBe(3); - }); -}); - -/** - * The batched oracle is a snapshot taken before the loop. Without the - * `remember` write-back, a name declared twice in one batch would take the - * INSERT branch the second time — and the loud ADR-0086 D4 refusal it used to - * produce would become a unique-index rejection nobody reports. - */ -describe('#10946 — a name declared twice in one batch keeps its loud refusal', () => { - it('still reports skippedForeign for a second package declaring the same name', async () => { - const ql = permissionQl([ - { name: 'shared_name', label: 'A', objects: {}, _packageId: 'com.example.a' }, - { name: 'shared_name', label: 'B', objects: {}, _packageId: 'com.example.b' }, - ]); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - expect(r.seeded).toBe(1); - expect(r.skippedForeign).toBe(1); - expect(ql.rows).toHaveLength(1); - expect(ql.rows[0].package_id).toBe('com.example.a'); - // [#17516] Re-anchored from the old prose to the stable token the report - // stamps — the assertion's substance (the refusal is REPORTED, not merely - // counted) is unchanged, and the record is asserted beside it so "loud" - // means reaching a reader rather than moving a counter. - expect(warns.some((w) => w.includes('permission_set_name_collision'))).toBe(true); - expect(r.collisions).toEqual([ - expect.objectContaining({ name: 'shared_name', declaredBy: 'com.example.b', ownedBy: 'com.example.a' }), - ]); - }); -}); - -/** - * [commit 1a6855226] `buildExistingByName` gained an optional bucket predicate for - * the curated capability read. The fence below holds the callers that pass - * none to the exact key set they emitted before. - */ -describe('#11451 — a caller that passes no predicate emits its old key set', () => { - it('the existing callers still emit their exact key set — no predicate leaked in', async () => { - // The measurement fence: `buildExistingByName` gained an optional predicate, - // and a caller that passes none must emit the keys it emitted before, not - // those keys plus `undefined`-valued ones. - const ql = permissionQl(declaredSets(3)); - await bootstrapDeclaredPermissions(ql, undefined); - ql.reset(); - await bootstrapDeclaredPermissions(ql, undefined); - expect(Object.keys(ql.wheres[0])).toEqual(['name']); - }); -}); diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts index e81d0383373..d74321a5e4b 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -44,7 +44,6 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { CAPABILITY_PLATFORM_NAME_REFUSED } from './seed-refusal-diagnostics.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -255,7 +254,6 @@ describe.each(SCENARIOS)('a $posture boot, the stack capability $declared', ({ p it('no capability seeder refuses or reports the platform’s own declarations', () => { for (const booted of [fresh, reseeded]) { - expect(booted.warnings.filter((w) => w.includes(CAPABILITY_PLATFORM_NAME_REFUSED))).toEqual([]); expect(booted.warnings.filter((w) => w.includes('capabilit'))).toEqual([]); } }); diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts index 37b9dbfb40b..90baecf594f 100644 --- a/packages/plugins/plugin-security/src/declared-capability-context.ts +++ b/packages/plugins/plugin-security/src/declared-capability-context.ts @@ -44,7 +44,31 @@ */ import type { AnchorBindingContext } from '@objectstack/spec/security'; -import { readDeclared } from './bootstrap-declared-permissions.js'; + +/** + * Read the declared metadata items of a type from the engine's + * `SchemaRegistry` — every package manifest's items, provenance-stamped with + * `_packageId`, plus the environment items `loadMetaFromDb` hydrates into the + * same collection. The registry is the catalog's one home (ADR-0131 D3); this + * is a read of it, never of a catalog row. + * + * [#8378] The registered item IS the authoring document — there is no + * `{ name, content }` envelope to unwrap: `registerMetadataCollections` + * (objectql `engine.ts`) registers each stack-collection element as-is, and + * `loadMetaFromDb` registers the parsed body, never the `sys_metadata` row. + * + * An unreadable or absent registry answers `[]`; the callers fall back to the + * metadata service or fail closed on an empty answer. + */ +export function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?.registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} /** * Read this stack's declared authorization capabilities as an diff --git a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts deleted file mode 100644 index b87b77821fb..00000000000 --- a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #21322 — a permission set registered AFTER the boot is projected into - * `sys_permission_set` when the kernel announces `metadata:reloaded`. - * - * ## The defect these pins are written against - * - * The declared-permission seeding (`bootstrapDeclaredPermissions`, ADR-0086 - * D5) ran once, inside the `kernel:ready` bootstrap, over whatever the engine - * registry held at that moment. A package registered later — `os package - * install` into a running runtime, whose install-local plugin registers the - * package from an HTTP request — was never projected: the evaluator resolved - * its set (`/meta/permission` listed it) while `sys_permission_set` had no - * row, so no admin could grant it until a restart re-ran the boot pass. - * - * The fix re-runs the SAME seeding on `metadata:reloaded`, the post-boot - * re-sync signal every runtime door announces. These pins drive the real - * `SecurityPlugin` through its own hooks — the wiring is the fix — against an - * engine double whose registry is mutable, so "registered after the boot" is - * a real ordering and not a fixture flag. The CLI suite - * `package-install-local-boot-steps.integration.test.ts` pins the same - * outcome end to end through `os package install`. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { SecurityPlugin } from './security-plugin.js'; - -const PACKAGE_ID = 'com.example.tasksapp'; -const PACKAGE_SET = { - name: 'tasks_app_task_user', - label: 'Tasks App Task User', - objects: { tasks_app_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - // The registry's provenance stamp (ADR-0010) — what `registerApp` leaves on - // every item a package contributes, and what the seeder reads as the owner. - _packageId: PACKAGE_ID, -}; - -/** - * `where` as the seeder spells it: field equality plus `{ name: { $in: [...] } }`. - * Anything else is REFUSED rather than answered — a combinator read as a field - * name matches no row and would report that as "no rows". - */ -function rowMatches(row: any, where: Record = {}): boolean { - return Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) { - throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } - const actual = row?.[k] ?? null; - if (v !== null && typeof v === 'object' && !Array.isArray(v)) { - const ops = Object.keys(v as Record); - if (ops.length === 1 && ops[0] === '$in') { - return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); - } - throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); - } - return actual === (v ?? null); - }); -} - -/** Boot the real plugin over an in-memory engine whose `permission` registry the test grows. */ -async function boot() { - const tables: Record = {}; - const declaredPermissions: any[] = []; - const ql: any = { - registerMiddleware: () => {}, - // Only the identity objects the plugin's boot gate requires: a kernel that - // boots it registers them (`authz-identity-objects-boot-refusal.test.ts`). - getSchema: (name: string) => - name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, - registry: { - listItems: (type: string) => (type === 'permission' ? [...declaredPermissions] : []), - }, - find: async (object: string, opts?: any) => { - const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); - // The caller's bound, applied BY PRESENCE and after the filter - // (`check:objectql-double-limit`). - return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); - }, - findOne: async (object: string, query?: EngineFindOneQueryInput) => { - // The producer's own predicate, never a hand-mirrored guard. - assertEngineFindOnePredicate(object, query); - return null; - }, - count: async () => 0, - insert: async (object: string, data: any) => { - const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; - (tables[object] ??= []).push(row); - return { ...row }; - }, - update: async (object: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - const target = (tables[object] ?? []).find((r) => r.id === (data?.id ?? options?.where?.id)); - if (target) Object.assign(target, data); - return target ? 1 : 0; - }, - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: ql, - metadata: { get: async () => null, list: async () => [] }, - }; - const hook = vi.fn(); - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - hook, - }; - const plugin = new SecurityPlugin(); - await plugin.init(ctx); - await plugin.start(ctx); - const fire = async (event: string) => { - for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb({ changed: [`app/${PACKAGE_ID}`] }); - }; - const packageRows = () => (tables.sys_permission_set ?? []).filter((r) => r.name === PACKAGE_SET.name); - return { ctx, fire, packageRows, declaredPermissions }; -} - -describe('#21322: declared permission sets registered after the boot are projected on metadata:reloaded', () => { - it('a set registered after kernel:ready gets its sys_permission_set row on the reload, with package provenance', async () => { - const { ctx, fire, packageRows, declaredPermissions } = await boot(); - await fire('kernel:ready'); - // Positive control: the boot pass really completed — without it, every - // assertion below would be satisfied by a boot that fell over early. - expect( - ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), - ).toHaveLength(1); - expect(packageRows(), 'the package was not registered yet — the boot pass had nothing to project').toHaveLength(0); - - declaredPermissions.push(PACKAGE_SET); // the hot install registers the package - await fire('metadata:reloaded'); - - expect(packageRows()).toHaveLength(1); - expect(packageRows()[0]).toMatchObject({ managed_by: 'package', package_id: PACKAGE_ID }); - }); - - it('is idempotent: a second reload over the same declaration adds no row', async () => { - const { fire, packageRows, declaredPermissions } = await boot(); - await fire('kernel:ready'); - declaredPermissions.push(PACKAGE_SET); - await fire('metadata:reloaded'); - await fire('metadata:reloaded'); - expect(packageRows()).toHaveLength(1); - }); - - it('does nothing before the boot pass has run — the platform defaults are seeded first, in their own shape', async () => { - const { fire, packageRows, declaredPermissions } = await boot(); - declaredPermissions.push(PACKAGE_SET); - await fire('metadata:reloaded'); - expect(packageRows()).toHaveLength(0); - // …and the boot pass that follows still projects it, as it always did. - await fire('kernel:ready'); - expect(packageRows()).toHaveLength(1); - }); -}); diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index e47e483edc3..c58beb5d571 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -17,16 +17,18 @@ * Every seam answered `[object Array]`, with no own `records` key, on a * populated page and an empty one alike. * - * ⚠️ These are six pins, not one pin repeated. A single reading of "the engine + * ⚠️ These are four pins, not one pin repeated. A single reading of "the engine * returns an array" would say nothing about whether a given seam is even - * reachable — six of them sit behind a trigger (a truncated page, a refused - * bulk write, a publish-materializer call path), and a seam nothing reaches is + * reachable — some of them sit behind a trigger (a truncated page, a refused + * bulk write), and a seam nothing reaches is * precisely how a dead limb survives review. So each case DRIVES its own block * through the real function and asserts the answer that block produced, and * {@link assertBareArrayPage} then reads back the value the engine handed it. * - * The seventh seam, `security-plugin.ts`'s `sys_permission_set` loader, is - * gone: the plugin takes a set's body from the security catalog + * Blocks 3 and 6 (the declared-permission seeder's per-name read and the + * `managed_by` normalizer's scan) retired with their modules (ADR-0131 D3: + * the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s + * `sys_permission_set` loader, is gone too: the plugin takes a set's body from the security catalog * (ADR-0131 D3/D4) and reads no `sys_permission_set` row, so it has no * engine page to pin. Its fault direction (a read that did not answer * propagates, and `PermissionEvaluator`'s #2565 warn fires) is pinned on the @@ -51,10 +53,8 @@ import { } from '@objectstack/metadata-core'; import { buildExistingByName } from './seed-name-lookup.js'; -import { upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; const SYS = { context: { isSystem: true } } as any; const ORG = 'org_a'; @@ -243,29 +243,6 @@ describe('[#15598] block 2 — seed-name-lookup perItemIndex (the DEGRADATION re }, 120_000); }); -describe('[#15598] block 3 — bootstrap-declared-permissions defaultLookup', () => { - it('answers from one bare array on the publish-materializer path', async () => { - const engine = await boot(); - const seen: Seen = []; - - // No `existingByName` — the ADR-0086 P2 publish materializer path, which is - // the only caller that reaches `defaultLookup`'s own read. - const outcome = await upsertPackagePermissionSet( - observed(engine, seen), - { name: 'ps_published', label: 'Published', objects: {} } as any, - 'com.acme.crm', - undefined, - {} as any, - ); - - assertBareArrayPage(seen, 'defaultLookup'); - // `unreadable` is what a seam that cannot read its page reports, and it is - // one-hot with `seeded` — so this pair is the discriminator. - expect(outcome.unreadable).toBe(0); - expect(outcome.seeded).toBe(1); - }, 120_000); -}); - describe('[#15598] block 4 — auto-org-admin-grant tryFind', () => { it('reads the set and the membership out of bare arrays, and grants on them', async () => { const engine = await boot(); @@ -321,19 +298,3 @@ describe('[#15598] block 5 — claim-seed-ownership idsFrom (the PAGING fallback expect(rows[0].owner_id).toBe(ADMIN); }, 120_000); }); - -describe('[#15598] block 6 — normalize-managed-by tryFind', () => { - it('reads the legacy rows out of one bare array and rewrites them', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const seen: Seen = []; - - const counts = await normalizeManagedByVocab(observed(engine, seen), {}); - - assertBareArrayPage(seen, 'normalize-managed-by tryFind'); - // The rewrite happened, which it cannot if the scan read nothing. - expect(counts.positions).toBe(1); - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('platform'); - }, 120_000); -}); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 7ba8d61c5dc..1d87a5bf8e4 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -54,11 +54,9 @@ export type { InvitationPlacementIntent, InvitationPlacementService, } from './invitation-placement.js'; -export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; // [#17516] The set-name collision diagnostic. EXPORTED because its whole -// purpose is to be the ONE derivation every door shares: the runtime door below -// raises it today, and the compile-time door (`os build` / `os validate`, which -// lives in another package) must consume these rather than re-deriving either +// purpose is to be the ONE derivation every door shares: the compile-time door +// (`os build` / `os validate`, which lives in another package) must consume these rather than re-deriving either // the predicate or the wording — that drift is what this card is about, one // layer up. export { @@ -72,32 +70,6 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-name-collision.js'; -// [#18091] The seeders' remaining refusals. EXPORTED on the criterion the -// block above states and no wider: a refusal an AUTHOR can cause has a second -// door by construction — the author-time one (`@objectstack/lint`, `os build` / -// `os validate`) — and a door that re-spells the token or re-derives the -// wording is the drift these modules exist to prevent. A package declaring a -// CURATED platform capability name, and a declaration carrying no owning -// package, are both decidable from the declaration alone with no database. -// ⛔ The two unreadable-rows summaries are deliberately NOT here: an unreadable -// database is a runtime condition no compile-time door can raise, so they stay -// package-private for the reason `position_name_fold_grant` does. -export { - CAPABILITY_DECLARATION_UNOWNED, - CAPABILITY_PLATFORM_NAME_REFUSED, - PERMISSION_SET_DECLARATION_UNOWNED, - capabilityDeclarationUnownedDiagnostic, - capabilityPlatformNameRefusedDiagnostic, - permissionSetDeclarationUnownedDiagnostic, - reportCapabilityDeclarationUnowned, - reportCapabilityPlatformNameRefused, - reportPermissionSetDeclarationUnowned, -} from './seed-refusal-diagnostics.js'; -export type { - CapabilityDeclarationUnownedDiagnostic, - CapabilityPlatformNameRefusedDiagnostic, - PermissionSetDeclarationUnownedDiagnostic, -} from './seed-refusal-diagnostics.js'; // [ADR-0094] sys_permission_set pure-projection machinery. export { permissionSetRowFields, @@ -119,7 +91,6 @@ export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissi export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; export type { ObjectPostureGateContext } from './object-posture-gate.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; -export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName, appSecurityPluginOptions, @@ -149,18 +120,6 @@ export type { SuggestionReconcileOutcome, WithheldAcceptance, } from './suggested-audience-bindings.js'; -// [field report — rc→GA declared≠enforced surfacing] "declared ≠ enforced" -// per-set diagnostics (overlay_shadow / provenance_skip). -export { - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - runPermissionSetDriftDiagnostics, -} from './permission-set-drift.js'; -export type { - PermissionSetDriftStatus, - PermissionSetDriftDiagnostic, - DriftDiagnosticsOptions, -} from './permission-set-drift.js'; // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] // Lock the base, clone to customize: the write-door provenance rule and its // refusals, plus the DETECTION READING for overlays that already exist. diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.test.ts b/packages/plugins/plugin-security/src/normalize-managed-by.test.ts deleted file mode 100644 index 502b878cc24..00000000000 --- a/packages/plugins/plugin-security/src/normalize-managed-by.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; - -/** - * A4 #2920 — the boot reconciler that heals legacy `managed_by` values on the - * RBAC catalogs onto the unified platform/package/admin vocabulary. - */ -function makeQl() { - const tables: Record = { - sys_position: [], - sys_permission_set: [], - }; - return { - tables, - async find(object: string, opts: any) { - const where = opts?.where ?? {}; - return (tables[object] ?? []).filter((r) => - Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }), - ); - }, - async update(object: string, data: any) { - const row = (tables[object] ?? []).find((r) => r.id === data.id); - if (row) Object.assign(row, data); - return row; - }, - }; -} - -describe('normalizeManagedByVocab (A4 #2920)', () => { - it('rewrites legacy position values system/config/user -> platform/package/admin', async () => { - const ql = makeQl(); - ql.tables.sys_position.push( - { id: 'p1', managed_by: 'system' }, - { id: 'p2', managed_by: 'config' }, - { id: 'p3', managed_by: 'user' }, - { id: 'p4', managed_by: 'platform' }, // already canonical - ); - const res = await normalizeManagedByVocab(ql); - expect(res.positions).toBe(3); - expect(ql.tables.sys_position.map((r) => r.managed_by).sort()).toEqual([ - 'admin', - 'package', - 'platform', - 'platform', - ]); - }); - - it('rewrites legacy permission-set value user -> admin, leaving platform/package untouched', async () => { - const ql = makeQl(); - ql.tables.sys_permission_set.push( - { id: 's1', managed_by: 'user' }, - { id: 's2', managed_by: 'package' }, - { id: 's3', managed_by: 'platform' }, - { id: 's4', managed_by: 'admin' }, - ); - const res = await normalizeManagedByVocab(ql); - expect(res.permissionSets).toBe(1); - expect(ql.tables.sys_permission_set.find((r) => r.id === 's1')!.managed_by).toBe('admin'); - expect(ql.tables.sys_permission_set.find((r) => r.id === 's2')!.managed_by).toBe('package'); - }); - - it('is idempotent — a second run is a no-op', async () => { - const ql = makeQl(); - ql.tables.sys_position.push({ id: 'p1', managed_by: 'system' }); - ql.tables.sys_permission_set.push({ id: 's1', managed_by: 'user' }); - await normalizeManagedByVocab(ql); - const res2 = await normalizeManagedByVocab(ql); - expect(res2).toEqual({ positions: 0, permissionSets: 0 }); - }); - - it('tolerates a ql without find/update', async () => { - expect(await normalizeManagedByVocab(null as any)).toEqual({ positions: 0, permissionSets: 0 }); - expect(await normalizeManagedByVocab({} as any)).toEqual({ positions: 0, permissionSets: 0 }); - }); -}); diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.ts b/packages/plugins/plugin-security/src/normalize-managed-by.ts deleted file mode 100644 index e3f38ae0526..00000000000 --- a/packages/plugins/plugin-security/src/normalize-managed-by.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * normalizeManagedByVocab — heal legacy `managed_by` values on the RBAC - * catalogs to the unified tri-state vocabulary (A4 #2920). - * - * The three RBAC catalogs (`sys_capability`, `sys_permission_set`, - * `sys_position`) historically spoke three different provenance dialects: - * - capability: platform / package / admin (already canonical) - * - permission set: platform / package / user - * - position: system / config / user - * - * A4 unifies all three on **platform / package / admin**. New rows are written - * canonically by the seeders/projector; this reconciler rewrites the residual - * legacy values on rows those writers do NOT re-touch — env-authored permission - * sets stamped `'user'`, and older tenant positions stamped `'system'` / - * `'config'` / `'user'`. Built-in position rows and declared package sets - * self-heal on their own bootstrap upsert, so this only mops up the rest. - * - * NOT purely cosmetic: the system-row write gate's provenance map - * (SYSTEM_ROW_PROVENANCE in security-plugin.ts) branches on `managed_by` - * values, so it must recognize BOTH the canonical and the legacy vocabulary — - * renaming a stored value without updating that map silently disarms the gate - * (#2926 ①). Keep the two in lockstep whenever this vocabulary changes. - * Idempotent: canonical rows are skipped, so a re-run is a no-op. - * Non-fatal to boot, like the sibling boot reconcilers — but [#15840] no longer - * best-effort about its own reads: a catalog read that does not answer refuses - * the pass instead of reporting the same counts an already-canonical catalog - * reports. The `kernel:ready` caller catches the refusal and carries on. - * - * Runs on `kernel:ready` after the seeders, as `isSystem` (the field is - * `readonly`, so only a system write may set it). - */ - -const SYSTEM_CTX = { isSystem: true }; - -/** legacy value -> canonical value, per object. */ -const POSITION_MAP: Record = { - system: 'platform', - config: 'package', - user: 'admin', -}; -const PERMISSION_SET_MAP: Record = { - user: 'admin', -}; - -interface NormalizeOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - /** - * [#15840] The level the ruling names for a refused pass. Optional, unlike - * its two siblings, so every caller that compiles today still compiles: it - * is an input this module asks for, not a channel it publishes. - * - * ⚠️ Three parameters, not two: the platform `Logger` contract - * (`packages/spec/src/contracts/logger.ts`) takes the `Error` in its OWN - * second argument at this level and only this level. Declaring the sibling - * `(message, meta)` shape here makes the real `ctx.logger` unassignable. - */ - error?: (message: string, error?: Error, meta?: Record) => void; - }; -} - -/** - * [#15840] Read the legacy rows, or REFUSE — never invent an empty catalog. - * - * The `catch { return []; }` that stood here is the read-seam invention rule's - * worst case, and it was measured (report 5553806224): an unreadable catalog and - * an already-canonical one were BYTE-IDENTICAL on both channels — the same - * `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while - * the row that needed healing stayed legacy. "I could not read the catalog" was - * reported as "the catalog is already canonical". - * - * #15840's ruling (decision batch #105 item 5, option A) is that a read fault - * REFUSES the normalisation pass for that batch and reports at `error`; ⛔ it - * never answers "already canonical". So the fault leaves this function as a - * throw, and {@link normalizeManagedByVocab} lets it out. - * - * The consumer contract for that throw already exists and is the reason a - * refusal is decidable here at all: `security-plugin.ts`'s `kernel:ready` - * bootstrap wraps this call in `try { … } catch { logger.warn('[security] - * managed_by vocab normalization failed (non-fatal)') }`, so boot proceeds and - * the remaining bootstrap steps still run. Nothing reached that handler before, - * because the fault was swallowed one frame below. - * - * ⚠️ Refusing is also the CHEAPER report. The report-and-continue option was - * measured at four lines per boot — this pass calls the read once per legacy - * value, three for `sys_position` and one for `sys_permission_set` — so a - * whole-catalog outage said the same thing four times. A refusal aborts at the - * first un-answered read, which is exactly one `error` line per refused boot. - * - * ⛔ Not a relaxation: the system-row write gate's provenance map recognizes - * BOTH the canonical and the legacy vocabulary (see the header note and - * #2926 ①), so rows left un-normalised by a refusal are still gated. Nothing is - * granted, widened or disarmed by declining to rewrite them; the next boot - * asks again. - */ -function readRefused(object: string, cause?: unknown): Error { - const why = cause === undefined ? 'the engine did not answer with a row array' : (cause as Error)?.message; - return new Error( - `[security] managed_by normalize REFUSED for ${object} — the catalog read did not answer, ` + - `so this pass cannot tell "already canonical" from "could not ask": ${why}`, - ); -} - -async function findOrRefuse(ql: any, object: string, where: any): Promise { - let rows: any; - try { - rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX }); - } catch (e) { - throw readRefused(object, e); - } - // Bare array, driven — `engine-find-bare-array.pin.test.ts` boots a real - // engine over a real `SqlDriver` and pins this seam. The `{ records }` limb - // that stood here was dead code that read as a contract; a non-array answer - // is now a refusal for the same reason a throw is — the pass did not get the - // rows, so it must not report on them. - if (Array.isArray(rows)) return rows; - throw readRefused(object); -} - -async function normalizeObject( - ql: any, - object: string, - map: Record, - logger?: NormalizeOptions['logger'], -): Promise { - let updated = 0; - for (const [legacy, canonical] of Object.entries(map)) { - // Narrow equality scan per legacy value keeps the where-clause - // driver-portable (no IN / OR predicate). - let rows: any[]; - try { - rows = await findOrRefuse(ql, object, { managed_by: legacy }); - } catch (e) { - // [#15840] The one report the ruling names, emitted where the count that - // will NOT be returned is still known: rows healed before the refusal - // stay healed, and saying so is the difference between a refusal and a - // rollback. Reported once — the throw aborts the whole pass. - logger?.error?.((e as Error).message, e instanceof Error ? e : undefined, { - object, - legacyValue: legacy, - healedBeforeRefusal: updated, - consequence: - 'the remaining legacy rows keep their legacy managed_by; the write gate recognizes ' + - 'both vocabularies, so nothing is disarmed, and the next boot asks again', - }); - throw e; - } - for (const row of rows) { - if (!row?.id) continue; - try { - await ql.update(object, { id: row.id, managed_by: canonical }, { context: SYSTEM_CTX }); - updated += 1; - } catch (e) { - logger?.warn?.(`[security] managed_by normalize failed for ${object}:${row.id}`, { - error: (e as Error).message, - }); - } - } - } - return updated; -} - -/** - * Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position` - * to the unified tri-state vocab. Returns a per-object count of rows healed. - * - * [#15840] THROWS if a catalog read does not answer. The returned counts are an - * attestation — "these rows were legacy and are now canonical" — and a pass that - * could not read the catalog has nothing to attest, so it refuses rather than - * reporting `{ positions: 0, permissionSets: 0 }`, which is what an - * already-canonical catalog reports. Callers already handle this: the - * `kernel:ready` bootstrap catches it, reports at `warn` as non-fatal, and - * continues. An engine with no `find`/`update` at all is NOT a read fault and - * still returns zeros. - */ -export async function normalizeManagedByVocab( - ql: any, - options: NormalizeOptions = {}, -): Promise<{ permissionSets: number; positions: number }> { - if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') { - return { permissionSets: 0, positions: 0 }; - } - const positions = await normalizeObject(ql, 'sys_position', POSITION_MAP, options.logger); - const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger); - const total = positions + permissionSets; - if (total > 0) { - options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin, the one vocabulary every RBAC catalog shares', { - positions, - permissionSets, - }); - } - return { permissionSets, positions }; -} 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 f03e9a2746e..10b5df747dd 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -1,48 +1,34 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#10103] The RBAC catalog is materialized PER ORGANIZATION. + * [#10103] The cross-organization grant bleed stays closed on the REAL + * resolver. * - * ## What was measured, and why these cases exist - * - * On a walled deployment every principal — org owner and platform admin alike — - * listed ZERO positions, permission sets and sharing rules while the tables held - * rows. The rows were all organization-less: this plugin's Layer 0 composes a - * strict `organization_id = :tenant`, the middleware ANDs it over the driver's - * `(organization_id = :tenant OR organization_id IS NULL)`, and the conjunction - * of the two is the strict equality alone. Nothing could be administered and a - * declared `hierarchy-security` could never be armed. - * - * The repair does not touch the wall at either layer. It gives the rows an - * owner. So every case here asserts about ROWS AND THEIR ORGANIZATION, through - * the real shipped seeders, never about a wall predicate. + * Cases 1–5 of this file pinned the per-organization materialization of the + * permission-set catalog rows. That materialization retired with the + * declared-permission seeder (ADR-0131 D3: the boot writes no catalog row, and + * every organization reads the same registry), so they went with it. What + * remains is the enforcement-plane half: an existing database still holds the + * per-organization `everyone` copies and junction rows the retired seeders + * wrote, and no organization's member may collect another organization's + * binding through them. * * ## Why a real driver, and a real engine * - * The whole defect lives in the interaction between a scope the ENGINE threads - * and a predicate the DRIVER emits. A hand-written engine double implements - * neither, so it reports green on a `tenantId` the real stack never applies — - * and it is precisely a double's silence that let the shipped behaviour read as - * correct for as long as it did. These cases therefore run a real `SqlDriver` on - * better-sqlite3 `:memory:` behind a real `ObjectQL`, and call the SHIPPED - * seeder functions rather than re-implementing their upserts. - * - * `case 6` additionally imports the REAL `resolveUserAuthzGrants` from - * `@objectstack/core` — the enforcement-plane half of this change — so the - * cross-organization grant bleed is pinned against the shipped resolver rather - * than a transcription of its shape. That import resolves through the package's - * `exports` to core's `dist/`, so core must be BUILT for this case to mean - * anything; the suite's `pnpm test` run is preceded by a dependency-closure - * build for exactly that reason. + * The defect lived in the interaction between a scope the ENGINE threads and a + * predicate the DRIVER emits, so this case runs a real `SqlDriver` on + * better-sqlite3 `:memory:` behind a real `ObjectQL`, and imports the REAL + * `resolveUserAuthzGrants` from `@objectstack/core`. That import resolves + * through the package's `exports` to core's `dist/`, so core must be BUILT for + * this case to mean anything; the suite's `pnpm test` run is preceded by a + * dependency-closure build for exactly that reason. */ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveUserAuthzGrants } from '@objectstack/core'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; @@ -62,35 +48,6 @@ afterEach(async () => { } }); -/** - * 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) => { - if (type === 'position') return [{ name: 'sales_manager', label: 'Sales Manager' }]; - if (type === 'permission') return [{ name: 'sales_readonly', label: 'Sales RO', _packageId: 'com.acme.crm', objects: {} }]; - return []; - }, - getItem: (type: string, name: string) => STUB_REGISTRY.listItems(type).find((item) => item.name === name), - isPackageDisabled: () => false, -}; - -/** A logger that records what the seeders said, so a LOUD guard can be asserted. */ -function recordingLogger() { - const warns: Array<{ message: string; meta: any }> = []; - const infos: Array<{ message: string; meta: any }> = []; - return { - warns, - infos, - logger: { - info: (message: string, meta?: any) => { infos.push({ message, meta }); }, - warn: (message: string, meta?: any) => { warns.push({ message, meta }); }, - }, - }; -} - async function boot(): Promise { const engine = new ObjectQL(); engine.registerDriver( @@ -124,176 +81,15 @@ async function boot(): Promise { return engine; } -/** - * The REAL engine, faced with the stub registry (`ObjectQL.registry` is a - * getter, so it cannot simply be assigned). - * - * A delegating pass-through, not a fake: every verb reaches the real ObjectQL - * behind it, which is the whole point of this suite. `update` still opens with - * 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 — the seeder does not delete, - * and a verb no caller exercises would be a contract this seam does not have to - * make. - */ -function withRegistry(engine: any): any { - return { - find: (o: string, q?: any, opt?: any) => engine.find(o, q, opt), - insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), - update: (o: string, d: any, opt?: any) => { - assertEngineUpdateDispatch(d, opt); - return engine.update(o, d, opt); - }, - registry: STUB_REGISTRY, - }; -} - /** Ground truth: every stored row, straight off knex, past all tenancy. */ async function stored(engine: ObjectQL, table: string): Promise { const driver: any = (engine as any).getDriver(table); return driver.knex(table).select('*'); } -/** - * 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 bootstrapDeclaredPermissions(ql, null, { logger, organizationId }); -} - const orgOf = (r: any): string | null => (r.organization_id ?? null); -describe('[#10103] per-organization RBAC catalog materialization', () => { - it('1. walled: every seeded catalog row is STAMPED with its organization, and each organization holds its own copy', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - 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(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) { - expect(sets.filter((r) => orgOf(r) === org).map((r) => r.name)).toEqual(['sales_readonly']); - } - }); - - it('2. walled: the read that measured ZERO over the whole catalog now returns the organization’s own rows', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - // 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_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).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); - }); - - it('3. THE silent no-op is gone: a per-organization pass over a PRE-FIX organization-less row creates the organization’s own copy AND says so loudly, by name', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - - // Reproduce a pre-fix deployment exactly: one organization-less pass, the - // shipped behaviour, writing the rows every deployment that booted the old - // code carries. - await seedCatalog(engine, logger, undefined); - const before = await stored(engine, 'sys_permission_set'); - expect(before.every((r) => orgOf(r) === null)).toBe(true); - 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); - - // (a) NOT a no-op. The measured failure was: the tenant-threaded pass sees - // 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 `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(setAfter.map(orgOf).sort((a, b) => `${a}`.localeCompare(`${b}`))) - .toEqual([ORG_JIA, ORG_YI, null].sort((a, b) => `${a}`.localeCompare(`${b}`))); - 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. - const guard = warns.filter((w) => w.message.includes('pre-fix organization-less')); - expect(guard.length).toBeGreaterThanOrEqual(2); // at least once per organization - for (const org of ORGS) { - 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('sales_readonly'); - } - // The remedy is spelled out, and the reason nothing is deleted with it. - const text = guard.map((w) => w.message).join(' '); - expect(text).toContain('re-initialize'); - expect(text).toContain('adopt'); - expect(text).toContain('NOT deleted'); - - // (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(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 () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - - await seedCatalog(engine, logger, undefined); - await seedCatalog(engine, logger, undefined); // idempotent re-run, as at boot - - 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([]); - }); - - it('5. boot reconciliation is O(changed declarations): a second pass over unchanged declarations writes NOTHING', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - // `updated_at` is the observable a blind re-write would move. Snapshot the - // whole row set, re-run every pass, and require byte-equality — a pass that - // re-wrote unchanged rows would fail here even if the VALUES matched. - const snapshot = JSON.stringify((await stored(engine, 'sys_position')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - const setSnapshot = JSON.stringify((await stored(engine, 'sys_permission_set')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - for (const org of ORGS) await seedCatalog(engine, logger, org); - - const again = JSON.stringify((await stored(engine, 'sys_position')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - const setAgain = JSON.stringify((await stored(engine, 'sys_permission_set')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - expect(again).toBe(snapshot); - expect(setAgain).toBe(setSnapshot); - }); - +describe('[#10103] per-organization RBAC catalog — the resolver', () => { it('6. the cross-organization grant bleed is closed: the REAL resolver collects no organization’s junction binding', async () => { const engine = await boot(); // [ADR-0131 D2] No boot seeder writes `everyone` rows any more; an existing diff --git a/packages/plugins/plugin-security/src/permission-set-drift.test.ts b/packages/plugins/plugin-security/src/permission-set-drift.test.ts deleted file mode 100644 index f1cf7524ee9..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-drift.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * "declared ≠ enforced" surfacing (field report: rc→GA upgraded envs freeze - * a package-declared permission set's grants, silently). Pins, one per test - * group: - * - * 1. a set whose enforced grants differ from the artifact is surfaced, - * naming the set AND the cause; - * 2. ⭐ the counter-direction — an in-sync set is NOT surfaced (`in_sync` is - * persisted as `null`, never a value that reads as a badge); - * 3. both mechanisms are detected AND correctly attributed, including the - * CONFOUNDED case where `managed_by` is wrong AND an overlay exists - * (the exact field-reported shape) — a detector that finds drift but - * mislabels the cause sends the operator to the wrong remedy. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - runPermissionSetDriftDiagnostics, -} from './permission-set-drift.js'; -import { permissionSetRowFields } from './permission-set-projection.js'; - -/** - * Minimal in-memory ql: sys_permission_set + sys_metadata + $in support. - * - * `refuseUpdatesWith` makes every `update` throw AFTER the dispatch predicate - * has accepted the call shape — the real failure ORDER (the engine validates, - * the store refuses), and the only one `tryUpdate`'s catch ever sees. Placing - * the throw first would let a call shape the real engine rejects pass this - * suite. Extended in place rather than added as a second double, deliberately: - * this file's `update` double is pinned 1-per-file in - * `scripts/engine-double-contract.pinned.json`. - */ -function makeQl(declared: any[] = [], refusal: { refuseUpdatesWith?: Error } = {}) { - const permRows: any[] = []; - const metaRows: any[] = []; - const tableFor = (object: string) => - object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; - const matches = (r: any, where: any) => - Object.entries(where ?? {}).every(([k, v]) => { - // REFUSE the combinators this double does not implement rather than - // reading `$and`/`$or` as a column name (check:where-matcher / - // #8494) -- neither code path under test here (drift detection reads - // sys_permission_set/sys_metadata by id/name/type/state, batched by - // `$in` via `buildExistingByName`) ever issues one; a matcher that - // silently treated a combinator as a field name would let a future - // combinator query pass this suite while answering the wrong rows. - if (k.startsWith('$')) { - throw new Error(`fake driver: unsupported combinator ${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; - }); - return { - permRows, - metaRows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - const rows = tableFor(object); - return rows ? rows.filter((r) => matches(r, q?.where)) : []; - }, - // Routed through the real dispatch predicate (#4434 / check:engine-double-contract): - // a fake looser than ObjectQL.update would let persistPermissionSetDriftDiagnostics - // drift to a call shape the real engine refuses while this suite stayed green. - async update(object: string, data: any, options?: any) { - const rows = tableFor(object); - const dispatch = assertEngineUpdateDispatch(data, options); - if (refusal.refuseUpdatesWith) throw refusal.refuseUpdatesWith; - const targets = dispatch.kind === 'by-id' - ? (rows ?? []).filter((r: any) => r.id === dispatch.id) - : (rows ?? []).filter((r: any) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'ehr_quality_inspector', - label: 'Quality Inspector', - objects: { - obj_a: { allowRead: true }, obj_b: { allowRead: true }, obj_c: { allowRead: true }, - }, // 3 objects — "the artifact" (stands in for the field report's 49) - _packageId: 'com.example.ehr', - ...over, -}); - -/** - * A row whose stored facets equal `declaredSet()`'s — built from the SAME - * `permissionSetRowFields` projection the real boot seeders write, so this - * fixture cannot drift from what `recordDiffersFromBody` actually compares - * (label / description / all five JSON facet columns). - */ -const inSyncRow = (over: Record = {}) => ({ - id: 'ps_1', - name: 'ehr_quality_inspector', - managed_by: 'package', - package_id: 'com.example.ehr', - ...permissionSetRowFields(declaredSet()), - ...over, -}); - -describe('computePermissionSetDriftDiagnostics — pin 2 (quiet case): an in-sync set is NOT surfaced', () => { - it('a package-owned row whose content matches the artifact reports in_sync (persisted as null)', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow()); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].status).toBe('in_sync'); - expect(diagnostics[0].detail).toBeNull(); - - const { updated } = await persistPermissionSetDriftDiagnostics(ql, diagnostics); - expect(updated).toBe(0); // row already had no drift_status — nothing to write - expect(ql.permRows[0].drift_status).toBeUndefined(); - }); - - it('a steady-state re-run issues ZERO writes once drift_status already reflects the truth (#10946 discipline)', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow({ drift_status: null, drift_detail: null })); - const { updated } = await runPermissionSetDriftDiagnostics(ql); - expect(updated).toBe(0); - }); -}); - -describe('computePermissionSetDriftDiagnostics — pin 1 & 3: both mechanisms detected AND correctly attributed', () => { - it("overlay_shadow: an active sys_metadata overlay shadows a managed_by:'package' row", async () => { - const artifact = declaredSet(); // 3-object artifact - const ql = makeQl([artifact]); - // row currently enforces only 2 objects (the overlay's stale content) - ql.permRows.push(inSyncRow({ - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - })); - ql.metaRows.push({ - id: 'meta_1', type: 'permission', name: 'ehr_quality_inspector', state: 'active', - organization_id: null, - metadata: JSON.stringify({ name: 'ehr_quality_inspector', objects: { obj_a: {}, obj_b: {} } }), - }); - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('overlay_shadow'); - expect(d.detail).toContain('overlay'); - expect(d.detail).not.toContain('Discard'); - }); - - it("provenance_skip: managed_by is NOT 'package' (legacy row), no overlay present", async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', // legacy / pre-provenance insert - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), // frozen at an old, smaller snapshot - })); - // no sys_metadata overlay row at all - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('provenance_skip'); - expect(d.detail).toContain('managed_by'); - expect(d.detail).not.toContain('overlay is shadowing'); // never conflate the two causes' wording - }); - - it('CONFOUNDED case (the exact field-reported shape): managed_by is wrong AND an overlay exists — attributed as overlay_shadow, not provenance_skip', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - // The row's managed_by is wrong (provenance-skip candidate) … - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - })); - // … AND an active overlay is ALSO present for the same name — the - // decisive stale source per the field follow-up. Detection must not be - // fooled by the wrong managed_by into calling this provenance_skip. - ql.metaRows.push({ - id: 'meta_1', type: 'permission', name: 'ehr_quality_inspector', state: 'active', - organization_id: null, - metadata: JSON.stringify({ name: 'ehr_quality_inspector', objects: { obj_a: {}, obj_b: {} } }), - }); - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('overlay_shadow'); // correctly attributed despite the confound - }); - - it('a set with no owning package is never diagnosed (nothing to compare against)', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined, packageId: undefined })]); - ql.permRows.push(inSyncRow({ managed_by: 'admin', object_permissions: '{}' })); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(0); - }); - - it('a name with no materialized row yet is skipped (boot seeding will create it, nothing to diagnose)', async () => { - const ql = makeQl([declaredSet()]); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(0); - }); -}); - -describe('persistPermissionSetDriftDiagnostics — writes are equality-gated', () => { - it('writes drift_status/drift_detail once, then issues zero further writes on an unchanged re-run', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - - const first = await runPermissionSetDriftDiagnostics(ql); - expect(first.updated).toBe(1); - expect(ql.permRows[0].drift_status).toBe('provenance_skip'); - expect(typeof ql.permRows[0].drift_detail).toBe('string'); - - const second = await runPermissionSetDriftDiagnostics(ql); - expect(second.updated).toBe(0); // nothing changed — no round trip - }); -}); - - -/* ------------------------------------------------------------------------- * - * pin 6 — a REFUSED diagnostic write must be LOUD - * - * The defect: `persistPermissionSetDriftDiagnostics` counted only the writes - * that LANDED, and `runPermissionSetDriftDiagnostics` reported only when that - * count was non-zero. So a boot on which every drift write was refused - * computed the drift correctly, persisted none of it, and printed NOTHING — - * byte-identical to a deployment with no drift, while the drifted sets kept - * enforcing grants that differ from the shipped artifact. - * - * Driver-error spellings are taken from the shipped classifier's own measured - * fixtures (see `seed-write-refusal.test.ts`), never invented here. - * ------------------------------------------------------------------------- */ - -/** Records every channel separately, with `error`'s real 3-arg shape. */ -function recordingLogger() { - const info: any[] = []; - const warn: any[] = []; - const error: any[] = []; - return { - info, warn, error, - sink: { - info: (m: string, meta?: any) => { info.push({ m, meta }); }, - warn: (m: string, meta?: any) => { warn.push({ m, meta }); }, - error: (m: string, e?: any, meta?: any) => { error.push({ m, e, meta }); }, - }, - }; -} - -/** NOT a unique violation — the realistic refusal for an update-by-id. */ -const connectionFailure = () => - Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); - -describe('runPermissionSetDriftDiagnostics — pin 6: a refused diagnostic write is visible', () => { - it('a pass whose every write is REFUSED still reports — the drifted set is named, and the refusal is reported on the durability channel', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', // provenance_skip — real drift - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - const log = recordingLogger(); - - const result = await runPermissionSetDriftDiagnostics(ql, { logger: log.sink }); - - // The drift was computed correctly and NONE of it landed. - expect(result.diagnostics[0].status).toBe('provenance_skip'); - expect(result.updated).toBe(0); - expect(result.refused).toBe(1); - expect(ql.permRows[0].drift_status).toBeUndefined(); - - // ⭐ The boot is no longer indistinguishable from a clean one. - expect(log.error).toHaveLength(1); - expect(log.error[0].m).toContain('REFUSED'); - expect(log.error[0].meta.refused).toBe(1); - expect(log.error[0].meta.refusals[0]).toMatchObject({ - object: 'sys_permission_set', class: 'other', count: 1, - }); - // The value-free code channel, never the bound statement. - expect(log.error[0].meta.refusals[0].driverCodes).toContain('ECONNREFUSED'); - // Never the catalog-seed prose: this pass seeds nothing, and sending an - // operator after a legacy platform-wide catalog index would be a - // confident wrong answer. See `reportDriftWriteRefusals`. - expect(log.error[0].m).not.toContain('RBAC catalog'); - - // …and the line that NAMES the drifted set is no longer gated behind the - // counter the refusal suppressed. - const drift = log.warn.find((l) => l.m.includes('differ from the shipped artifact')); - expect(drift).toBeDefined(); - expect(drift!.meta.drifted).toEqual([{ name: 'ehr_quality_inspector', status: 'provenance_skip' }]); - expect(drift!.meta.updated).toBe(0); - expect(drift!.meta.refused).toBe(1); - }); - - it('⭐ counter-direction: a steady-state pass (nothing to write, nothing refused) still prints NOTHING', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow({ drift_status: null, drift_detail: null })); - const log = recordingLogger(); - - const result = await runPermissionSetDriftDiagnostics(ql, { logger: log.sink }); - - expect(result.updated).toBe(0); - expect(result.refused).toBe(0); - // The new `refused > 0` limb re-opens the refusal case and nothing else — - // a quiet boot stays exactly as quiet as it was. - expect(log.error).toHaveLength(0); - expect(log.warn).toHaveLength(0); - }); - - it('against a REDUCED sink with no `error`, the refusal still prints — at `warn`, never nowhere', async () => { - const ql = makeQl([declaredSet()], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - const warn: any[] = []; - - // `{ warn }` alone is a legal ProjectionLogger — hosts do inject reduced - // sinks, which is exactly why the durability fallback is mandatory. - await runPermissionSetDriftDiagnostics(ql, { logger: { warn: (m: string, meta?: any) => { warn.push({ m, meta }); } } }); - - expect(warn.some((l) => l.m.includes('REFUSED'))).toBe(true); - }); -}); - - -/* ------------------------------------------------------------------------- * - * [#21860] the declared population is the lock classifier's - * - * A set saved into a writable runtime package carries its package id on its - * registry body after the first metadata list read - * (`{ _packageId: , _provenance: 'org' }`). This report read - * that as "package-declared": a drifted runtime-package set with a stored - * definition was diagnosed `overlay_shadow`. - * ------------------------------------------------------------------------- */ - -const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg'; - -/** Registry bodies as the hydrator registers them for each environment-authored shape. */ -const envShapes = [ - { label: 'runtime-package set', body: { name: 'rt_pkg_set', label: 'Runtime package set', objects: { obj_a: { allowRead: true } }, _packageId: RUNTIME_PACKAGE, _provenance: 'org' }, packageId: RUNTIME_PACKAGE }, - { label: 'org-owned set', body: { name: 'org_owned_set', label: 'Org-owned set', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null }, - { label: 'clone', body: { name: 'contributor_clone', label: 'Contributor (clone)', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null }, - // The lock module's documented runtime shadow: the `'sys_metadata'` sentinel package id. - { label: 'runtime shadow', body: { name: 'runtime_only_set', label: 'Runtime-only set', objects: { obj_a: { allowRead: true } }, _packageId: 'sys_metadata', _provenance: 'org' }, packageId: null }, -] as const; - -/** A record that DIFFERS from the body (so a judged set would be surfaced), plus its stored definition. */ -function seedDrifted(ql: ReturnType, body: any, packageId: string | null): string { - const id = `ps_${body.name}`; - ql.permRows.push({ - id, name: body.name, managed_by: 'admin', package_id: packageId, - ...permissionSetRowFields(body), - object_permissions: JSON.stringify({ obj_z: { allowRead: true } }), - }); - ql.metaRows.push({ - id: `meta_${body.name}`, type: 'permission', name: body.name, state: 'active', organization_id: null, - package_id: packageId, metadata: JSON.stringify(body), - }); - return id; -} - -describe('[#21860] computePermissionSetDriftDiagnostics — the declared population is the lock classifier\'s', () => { - for (const shape of envShapes) { - it(`a drifted ${shape.label} with a stored definition is never diagnosed`, async () => { - const ql = makeQl([declaredSet({ _provenance: 'package' }), shape.body]); - ql.permRows.push(inSyncRow()); - seedDrifted(ql, shape.body, shape.packageId); - - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - - // Only the code-shipped set is judged; the environment's own set is not. - expect(diagnostics.map((d) => ({ name: d.name, status: d.status, packageId: d.packageId }))) - .toEqual([{ name: 'ehr_quality_inspector', status: 'in_sync', packageId: 'com.example.ehr' }]); - }); - } -}); diff --git a/packages/plugins/plugin-security/src/permission-set-drift.ts b/packages/plugins/plugin-security/src/permission-set-drift.ts deleted file mode 100644 index 5beec3e4326..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-drift.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * "declared ≠ enforced" surfacing for package-declared `sys_permission_set` - * rows (field report: rc→GA upgraded envs freeze a package's permission set - * at a first-boot/overlay snapshot while the shipped artifact keeps moving — - * grants stay frozen for weeks, silently, and the only signal was a boot log - * counter). - * - * TWO INDEPENDENT MECHANISMS can make a package-declared set's ENFORCED - * grants (what `sys_permission_set` actually stores, and what the evaluator - * resolves) diverge from its shipped ARTIFACT (what `readDeclared` reads from - * the engine's SchemaRegistry, the same source `bootstrapDeclaredPermissions` - * seeds from) — and neither may be assumed to be the only one live on a given - * environment: - * - * - **`overlay_shadow`** — an active `sys_metadata` overlay row exists for - * the set's name (a Studio permission-matrix save materialized one, back - * when `permission` still allowed org overrides, or through the - * `OS_METADATA_WRITABLE` hatch since). `reconcilePermissionSetProjection` - * re-projects that overlay's content onto the record on EVERY boot - * (ADR-0094 D4 step 1, unconditionally, keyed only on overlay presence), - * so the overlay wins forever regardless of what the package ships next. - * - **`provenance_skip`** — the row's `managed_by` is not `'package'` (a - * legacy/pre-provenance insert — see `bootstrap-declared-permissions.ts`), - * so `upsertPackagePermissionSet`'s package door refuses to touch it - * (`skippedEnvAuthored`) on every boot, forever. - * - * ⚠️ THE TWO MECHANISMS CONFOUND EACH OTHER. When BOTH are present on one row - * — the exact field-report shape — `upsertEnvPermissionSet`'s `customized` - * flag (`existing.managed_by === 'package' ? !!customized : false`) is - * FORCED FALSE, because the row's `managed_by` is wrong (provenance_skip), - * even though an overlay genuinely IS shadowing it. That is measurably why - * `customized` stayed `0` on the field-reported environment — the ADR-0094 - * surface an admin would check said "not customized" while an overlay had - * frozen the set for two weeks. So detection here does NOT trust - * `managed_by` to decide whether an overlay is shadowing a row — overlay - * presence is checked directly against `sys_metadata`, independent of the - * row's (possibly wrong) provenance column, exactly so this confounded case - * is still attributed correctly (`overlay_shadow`, not silently missed). - * - * WHAT THIS MODULE DOES NOT DO: it never guesses which mechanism explains a - * mismatch — it re-derives the fact each boot from the current `sys_metadata` - * / `sys_permission_set` state, the same sources the two write-doors read. - * It also never adopts/migrates a row (out of scope by 2026-08-20 maintainer - * ruling — "新项目还没上线,不需要清理旧数据,也没有老客户升级"). - * - * ⚠️ Scope note shared with `reconcilePermissionSetProjection`: only - * ENV-WIDE overlays (`sys_metadata.organization_id IS NULL`) are considered - * — the per-organization overlay residue the projection file's header already - * documents as deliberately out of scope (#10103) stays out of scope here - * too, for the same reason (this module answers the identical "is there an - * overlay" question the reconciler already answers, from the same rows). - */ - -import { - recordDiffersFromBody, - tryFind, - tryUpdate, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - createSeedWriteRefusals, - logSeedDurabilityFailure, -} from './write-refusals.js'; -import { readDeclared } from './bootstrap-declared-permissions.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; -import { buildExistingByName } from './seed-name-lookup.js'; - -/** - * Cause a package-declared set's enforced grants can diverge from its shipped - * artifact. `in_sync` and `other` are internal/testing values only — - * {@link persistPermissionSetDriftDiagnostics} writes `null` for `in_sync` - * (pin: an in-sync set must not be surfaced at all, not even as a quiet - * "ok" badge) and keeps `other` as a truthful catch-all for the one case - * neither named mechanism explains (a package-owned row whose own boot-seed - * write failed — `unreadable` — so its content is stale for a reason that is - * neither an overlay nor a provenance skip). - */ -export type PermissionSetDriftStatus = 'in_sync' | 'overlay_shadow' | 'provenance_skip' | 'other'; - -export interface PermissionSetDriftDiagnostic { - id: string; - name: string; - packageId: string; - status: PermissionSetDriftStatus; - /** Human-readable detail naming the grant-count mismatch and the cause. `null` when in sync. */ - detail: string | null; - /** The row's stored `drift_status` before this pass (already normalized: `'in_sync'` read back as `null`). */ - priorStatus: string | null; - priorDetail: string | null; -} - -export interface DriftDiagnosticsOptions { - logger?: ProjectionLogger; - organizationId?: string; -} - -function countGrantedObjects(objects: any): number { - return objects && typeof objects === 'object' ? Object.keys(objects).length : 0; -} - -/** True when `row` carries an ACTIVE env-wide `sys_metadata` overlay for `name` (independent of `managed_by`). */ -function buildOverlayNameSet(rows: any[]): Set { - const out = new Set(); - for (const r of rows) { - if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide overlays only — see module header - out.add(String(r.name)); - } - return out; -} - -/** - * Compute (never write) the drift diagnostic for every currently package- - * declared permission set. Pure function over the passed `ql` — a boot pass - * and a test both call this the same way; {@link persistPermissionSetDriftDiagnostics} - * is the only thing that writes. - */ -export async function computePermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise { - const out: PermissionSetDriftDiagnostic[] = []; - if (!ql || typeof ql.find !== 'function') return out; - - // [#21860] "Package-declared" is the lock's verdict for the NAME — a code - // (artifact) package ships it — read through the one classifier the write - // doors and the overlay detection reading ask. - // A package id on the item alone is not that answer: a set saved into a - // writable runtime package carries one after the first metadata list read, - // and this report used to diagnose it as package-declared. - // The per-item test that follows the verdict only picks which bodies of a - // code-shipped name are compared, exactly as before. - const codeShipped = new Map(); - const isCodeShipped = (name: string): boolean => { - let shipped = codeShipped.get(name); - if (shipped === undefined) { - shipped = classifyPackagedPermissionSet(name, ql).status === 'packaged'; - codeShipped.set(name, shipped); - } - return shipped; - }; - const declared = readDeclared(ql, 'permission').filter( - (ps) => ps?.name && (ps._packageId ?? ps.packageId) && isCodeShipped(String(ps.name)), - ); - if (declared.length === 0) return out; - - const organizationId = opts.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - declared.map((ps) => ps.name), - opts.logger, - organizationId, - ); - - // Same bulk overlay read `reconcilePermissionSetProjection` performs (ADR-0094 - // D4 step 1) — both `permission` and its legacy plural spelling. - const overlayRows: any[] = []; - for (const type of ['permission', 'permissions']) { - overlayRows.push(...(await tryFind(ql, 'sys_metadata', { type, state: 'active' }, 1000))); - } - const overlayNames = buildOverlayNameSet(overlayRows); - - for (const ps of declared) { - const name = String(ps.name); - const packageId = String(ps._packageId ?? ps.packageId); - const lookup = await existingByName.get(name); - if (lookup.status !== 'present') continue; // not yet materialized — nothing to diagnose (boot seeding creates it) - const row = lookup.row; - const priorStatus: string | null = row?.drift_status ?? null; - const priorDetail: string | null = row?.drift_detail ?? null; - - const differs = recordDiffersFromBody(row, ps); - if (!differs) { - out.push({ id: row.id, name, packageId, status: 'in_sync', detail: null, priorStatus, priorDetail }); - continue; - } - - const declaredCount = countGrantedObjects(ps.objects); - const enforcedCount = countGrantedObjects( - (() => { try { return JSON.parse(row.object_permissions ?? '{}'); } catch { return {}; } })(), - ); - const hasOverlay = overlayNames.has(name); - - let status: PermissionSetDriftStatus; - let detail: string; - if (hasOverlay) { - status = 'overlay_shadow'; - detail = - `An environment customization overlay (sys_metadata, type:'permission', name:'${name}') is shadowing ` + - `this package-declared set — enforced grants (${enforcedCount} object(s)) come from the overlay, not ` + - `the shipped artifact (${declaredCount} object(s)).`; - } else if (row.managed_by !== 'package') { - status = 'provenance_skip'; - detail = - `This row's managed_by ('${row.managed_by ?? 'none'}') predates package provenance tracking, so boot ` + - `sync treats it as environment-authored and never reconciles it with the package — enforced grants ` + - `(${enforcedCount} object(s)) are frozen at an old snapshot vs. the shipped artifact's ` + - `${declaredCount} object(s).`; - } else { - status = 'other'; - detail = - `Enforced grants (${enforcedCount} object(s)) differ from the shipped artifact (${declaredCount} ` + - `object(s)) for a reason that is neither an overlay nor a provenance skip — check boot logs for ` + - `'[security] declared permission sets left untouched'.`; - } - out.push({ id: row.id, name, packageId, status, detail, priorStatus, priorDetail }); - } - return out; -} - -/** - * Report, ONCE per pass, the drift-diagnostic writes the store refused. - * - * ## Why this is not {@link reportSeedWriteRefusals} - * - * The shared ACCUMULATOR (`createSeedWriteRefusals`) is reused as-is — it - * carries the shipped `isUniqueViolationError` classification and the - * value-free `code`/`errno` channel, and re-deriving either here is exactly - * the local-regex defect that module was written to retire. The shared - * REPORTER is not, and deliberately: every sentence it prints is about - * seeding the RBAC catalog — "the catalog is INCOMPLETE", "this pass's - * 'seeded' count", and a remedy naming the legacy PLATFORM-WIDE unique index - * on the catalog name column. None of that is true here. This pass seeds - * nothing; it writes two diagnostic columns onto rows that already exist, by - * id. Printing that text over this failure would send an operator to - * `os migrate` for a defect that is not there — the same "a confident wrong - * answer is worse than no answer" reasoning that makes `other` its own class - * in `reportSeedWriteRefusals` rather than a relabelled unique violation. - * - * ## Why ONE line and not one per class - * - * `reportSeedWriteRefusals` splits its classes because their REMEDIES differ. - * Here they do not: an update-by-id of two nullable diagnostic columns has no - * unique constraint to violate, so the class is a fact for `meta` (it travels - * there, per (object, class), with the driver codes) and not a reason to print - * a second sentence. - * - * ## Why the durability channel - * - * AGENTS.md "Degradation log levels", one question — after the degradation, - * does the system still look normal while something it claims is persisted - * has not landed? Yes, precisely: `drift_status` / `drift_detail` are what - * Setup's "Needs Attention" surface reads, so a refused write leaves that - * screen showing a CLEAN environment over sets that are still drifted. - * ⚠️ `check:durability-log-level` does not vouch for this choice — `ql.update` - * is not in its `DURABILITY_CRITICAL_CALLEES` vocabulary, so its green here - * means the site is outside the gate's reach (NOT MEASURED), never approval. - */ -function reportDriftWriteRefusals( - logger: ProjectionLogger | undefined, - refusals: ReturnType, - organizationId?: string, -): void { - const entries = refusals.report(); - if (entries.length === 0) return; - logSeedDurabilityFailure( - logger, - `[security] ${refusals.total} package-declared permission set drift diagnostic(s) were REFUSED by the ` + - `store — the "declared ≠ enforced" verdict this pass computed did NOT persist, so those rows keep ` + - `their PREVIOUS drift_status/drift_detail (usually none at all), Setup's "Needs Attention" surface goes ` + - `on showing them as CLEAN, and THE DEPLOYMENT WILL GO ON LOOKING HEALTHY while those sets keep ` + - `enforcing grants that differ from the shipped artifact. The drifted set NAMES are not lost with the ` + - `write: the "[security] package-declared permission set(s) enforcing grants that differ from the ` + - `shipped artifact" line is logged alongside this one and names every one of them — it is no longer ` + - `gated behind the count of writes that landed. What the store actually said is in the query engine's ` + - `"Update operation failed" entries logged just before this one, which keep the driver's own identifier ` + - `with the bound statement and its values cut. Remedy: read those entries — a sys_permission_set ` + - `missing its drift_status/drift_detail columns is a deployment SCHEMA defect, reported by ` + - `"os migrate plan" and fixed by "os migrate apply"; anything else is a store outage, which this pass ` + - `re-computes and re-attempts on the next boot. Either way nothing is lost, and nothing is recorded ` + - `either until a write lands.`, - { - refused: refusals.total, - refusals: entries, - ...(organizationId ? { organization: organizationId } : {}), - }, - ); -} - -/** - * Write the computed diagnostics onto their `sys_permission_set` rows. - * EQUALITY-GATED (#10946 discipline): a row whose stored `drift_status` / - * `drift_detail` already match is left untouched — a steady-state boot pays - * zero writes, matching the round-trip contract every other pass in this file - * keeps. - * - * `in_sync` is written as `null` — the "not surfaced" pin is a data-level - * fact, not merely a filtered view: a quiet set carries no `drift_status` - * value at all, so a client reading the raw record (not only the Setup - * "Needs Attention" view) sees nothing to worry about either. - * - * Answers `refused` alongside `updated`. ⚠️ They are not two spellings of the - * same pass: `updated` counts the verdicts that LANDED and `refused` counts - * the ones the store rejected, and a caller asking "was there drift?" while - * reading only `updated` reads a wholly refused pass as a clean one — the - * defect {@link reportDriftWriteRefusals} and the gate in - * {@link runPermissionSetDriftDiagnostics} exist to close. - */ -export async function persistPermissionSetDriftDiagnostics( - ql: any, - diagnostics: readonly PermissionSetDriftDiagnostic[], - opts: DriftDiagnosticsOptions = {}, -): Promise<{ updated: number; refused: number }> { - // ⛔ The refusal LOG is what makes a refused write distinguishable from - // "nothing to write". `tryUpdate` answers `false` for both, and this - // function's only output used to be a count of the writes that LANDED — so - // a pass whose every write was refused returned `{ updated: 0 }`, which is - // byte-identical to the steady-state boot the equality gate above is built - // to produce. See the report below for what that silence cost. - const refusals = createSeedWriteRefusals(); - let updated = 0; - for (const d of diagnostics) { - const status: string | null = d.status === 'in_sync' ? null : d.status; - const detail: string | null = d.status === 'in_sync' ? null : d.detail; - if (d.priorStatus === status && d.priorDetail === detail) continue; - if (await tryUpdate(ql, 'sys_permission_set', { id: d.id, drift_status: status, drift_detail: detail }, opts.organizationId, refusals)) { - updated += 1; - } - } - reportDriftWriteRefusals(opts.logger, refusals, opts.organizationId); - return { updated, refused: refusals.total }; -} - -/** Compute + persist in one call — what boot wiring uses. */ -export async function runPermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise<{ diagnostics: PermissionSetDriftDiagnostic[]; updated: number; refused: number }> { - const diagnostics = await computePermissionSetDriftDiagnostics(ql, opts); - const { updated, refused } = await persistPermissionSetDriftDiagnostics(ql, diagnostics, opts); - // ⛔ `updated > 0` ALONE was the suppressor. A boot on which every drift - // write is refused computes the drift correctly, persists none of it, and — - // under the old gate — printed nothing at all, which is byte-identical to a - // deployment with no drift. `refused > 0` re-opens exactly that case and - // nothing else: a steady-state boot (equality-gated, nothing to write, - // nothing refused) stays as quiet as it was. - if (updated > 0 || refused > 0) { - opts.logger?.warn?.( - '[security] package-declared permission set(s) enforcing grants that differ from the shipped artifact', - { - updated, - // Present ONLY when non-zero, so the steady-state line is unchanged - // byte-for-byte for anything reading it. Read it together with - // `updated`: `updated` counts the verdicts that LANDED, never the - // verdicts that were reached. - ...(refused > 0 ? { refused } : {}), - drifted: diagnostics.filter((d) => d.status !== 'in_sync').map((d) => ({ name: d.name, status: d.status })), - ...(opts.organizationId ? { organization: opts.organizationId } : {}), - }, - ); - } - return { diagnostics, updated, refused }; -} diff --git a/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts b/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts index 7e73aa0b7e6..b0f0817081d 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts @@ -1,41 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#17516] The set-name collision refusal must REACH THE AUTHOR. + * [#17516] The set-name collision diagnostic — the ONE derivation every door + * that refuses a foreign set name shares. * - * ## What these pins are about, and what they deliberately do not touch - * - * ⛔ Not the skip. Refusing to write into a `sys_permission_set` row another - * package owns is correct under ADR-0086 D4 and is asserted UNCHANGED in every - * case below (`skippedForeign` still counts, the foreign row is never mutated). - * The defect was that the refusal was invisible: `logger?.warn?.(…)` at that - * branch is optionally chained TWICE, so a caller passing no logger produced no - * output at all and an entire declared permission set disappeared with one - * internal counter moved. - * - * ## The measurement these replace - * - * On the pre-fix tree the first case below measured ZERO console lines across - * every channel, and `undefined` for the outcome's diagnostic records, while - * `skippedForeign` was 1. That is the card's reading, reproduced. - * - * ## The discriminating half - * - * A pin that only asserts "something was printed on a collision" passes just as - * well against a seeder that prints on EVERY seeded set, which would be a - * different defect (#12015's: a diagnostic that fires always is as unreadable as - * one that never fires). So the no-collision control asserts SILENCE on all - * five console channels, over a pass that really does seed and really does - * re-seed its own row. + * The runtime door that raised it — the declared-permission seeder's refusal to + * write into a `sys_permission_set` row another package owns — retired with the + * seeder (ADR-0131 D3: the boot writes no catalog row), and its pins went with + * it. The compile-time door (`os build` / `os validate`) still consumes the + * predicate and the wording from here, so the derivation keeps its own pins. */ import { describe, it, expect, vi } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { - bootstrapDeclaredPermissions, - upsertPackagePermissionSet, -} from './bootstrap-declared-permissions.js'; import { PERMISSION_SET_NAME_COLLISION, formatPermissionSetNameCollisionDiagnostic, @@ -44,62 +21,6 @@ import { reportPermissionSetNameCollisions, } from './permission-set-name-collision.js'; -/** Minimal in-memory ql + registry for sys_permission_set seeding. */ -function makeQl(declared: any[] = []) { - const rows: any[] = []; - return { - rows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const hits = rows.filter((r) => Object.entries(where).every(([k, v]) => { - // ⛔ REFUSE what this double does not implement. A `$or` / `$and` read - // as a FIELD NAME is the silently-wrong shape: `r.$or` is `undefined`, - // no row matches, and a suite asserts on an empty result set with - // nothing erroring (`check:where-matcher`, shape (b)). The sibling - // double in `objects/reserved-identity-names.test.ts` refuses the same way. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${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; - })); - // Hold the caller's BOUND, after the filter and by PRESENCE, so `limit: 0` - // returns nothing rather than everything. `defaultLookup` really does read - // `{ where: { name }, limit: organizationId ? 5 : 1 }` (#10103), and a - // limit-blind double cannot tell that read from an unbounded one - // (`check:objectql-double-limit`). - return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - // `assertEngineUpdateDispatch` rather than a hand-rolled id check: a fake - // looser than `ObjectQL.update` is how a dead route once shipped with its - // suite green (`check:engine-double-contract`). - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - if (object !== 'sys_permission_set') return; - if (dispatch.kind !== 'by-id') throw new Error('fake driver: only by-id update is modelled'); - const r = rows.find((x) => x.id === dispatch.id); - if (r) Object.assign(r, data); - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true } }, - _packageId: 'com.example.b', - ...over, -}); - /** * Capture EVERY console channel. "Author-visible output" is not channel-specific * — the pre-fix failure was that NONE of them carried anything — so a pin that @@ -115,168 +36,6 @@ function captureConsole() { return { seen, restore: () => spies.forEach((s) => s.mockRestore()) }; } -describe('[#17516] set-name collision reaches the author', () => { - it('prints, WITH NO LOGGER INJECTED, and returns the diagnostic record', async () => { - const ql = makeQl([declaredSet()]); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - // No logger — the exact call shape under which the old branch was mute. - out = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - // ⛔ The refusal itself is UNCHANGED. - expect(out.skippedForeign).toBe(1); - expect(out.seeded).toBe(0); - expect(out.updated).toBe(0); - expect(ql.rows).toHaveLength(1); - expect(ql.rows[0].package_id).toBe('com.example.a'); - expect(ql.rows[0].object_permissions).toBe('{}'); - - // The half that was missing: the author is told, through the console, - // because no sink was injected (#10556 — silent-by-declaration rejected). - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_NAME_COLLISION); - expect(cap.seen[0]).toMatch(/^warn:/); - expect(cap.seen[0]).toContain('NOT materialized'); - - // And the RECORD, for a caller that reads no log at all. - expect(out.collisions).toHaveLength(1); - const d = out.collisions![0]!; - expect(d.event).toBe(PERMISSION_SET_NAME_COLLISION); - expect(d.severity).toBe('warning'); - expect(d.name).toBe('crm_sales_rep'); - expect(d.declaredBy).toBe('com.example.b'); - expect(d.ownedBy).toBe('com.example.a'); - // The remedy names both legal resolutions, including the ADR-0130 D1 one - // the old comment's premise denied could exist. - expect(d.fix).toContain('ADR-0130 D1'); - }); - - it('DISCRIMINATING CONTROL: a pass with no collision says nothing at all', async () => { - // Two real writes happen here — a first seed and an own-row re-seed — so - // the silence is over a pass that is doing work, not an empty one. - const ql = makeQl([declaredSet({ _packageId: 'com.example.a' })]); - - const first = captureConsole(); - let r1: Awaited>; - try { - r1 = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - first.restore(); - } - expect(r1.seeded).toBe(1); - expect(r1.skippedForeign).toBe(0); - expect(r1.collisions).toBeUndefined(); - expect(first.seen).toEqual([]); - - // Re-seed the SAME package's own row after a declaration change. - (ql as any).registry = { - listItems: () => [declaredSet({ _packageId: 'com.example.a', objects: { crm_lead: { allowRead: true, allowCreate: true } } })], - }; - const second = captureConsole(); - let r2: Awaited>; - try { - r2 = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - second.restore(); - } - expect(r2.updated).toBe(1); - expect(r2.skippedForeign).toBe(0); - expect(r2.collisions).toBeUndefined(); - expect(second.seen).toEqual([]); - }); - - it('reports through an INJECTED sink instead of the console when one is given', async () => { - const ql = makeQl([declaredSet()]); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const warned: Array<{ m: string; meta?: Record }> = []; - const cap = captureConsole(); - try { - await bootstrapDeclaredPermissions(ql, undefined, { - logger: { warn: (m, meta) => { warned.push({ m, meta }); } }, - }); - } finally { - cap.restore(); - } - - const collision = warned.filter((w) => w.m.includes(PERMISSION_SET_NAME_COLLISION)); - expect(collision).toHaveLength(1); - expect(collision[0]!.meta?.collisions).toEqual([ - expect.objectContaining({ name: 'crm_sales_rep', declaredBy: 'com.example.b', ownedBy: 'com.example.a' }), - ]); - // The host sink REPLACES the console default — not both. - expect(cap.seen).toEqual([]); - }); - - it('the ADR-0086 P2 single-set path reports too — it has no pass to summarise', async () => { - const ql = makeQl(); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - // No logger, no collector — the publish materializer's exact call shape. - out = await upsertPackagePermissionSet(ql, declaredSet(), 'com.example.b'); - } finally { - cap.restore(); - } - - expect(out.skippedForeign).toBe(1); - expect(out.collisions).toHaveLength(1); - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_NAME_COLLISION); - }); - - it('a package-managed row with NO package_id is foreign, not adoptable', async () => { - const ql = makeQl([declaredSet()]); - // ADR-0086 D3's exact ambiguity: managed_by 'package', owner unprovable. - ql.rows.push({ - id: 'ps_unowned', - name: 'crm_sales_rep', - managed_by: 'package', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - out = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(out.skippedForeign).toBe(1); - expect(out.updated).toBe(0); - expect(ql.rows[0].object_permissions).toBe('{}'); - expect(out.collisions![0]!.ownedBy).toBeNull(); - expect(cap.seen).toHaveLength(1); - }); -}); - describe('[#17516] the shared derivation both doors must use', () => { it('permissionSetNameIsForeign: same owner is ours, anything else is foreign', () => { expect(permissionSetNameIsForeign('com.example.a', 'com.example.a')).toBe(false); diff --git a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts index 973e700e495..813dc8929e9 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -21,8 +21,9 @@ * ⛔ not a uniform sweep. The pins below moved accordingly, and this diff is the * record of the ruling. What each seam now says: * - * - `normalize-managed-by.ts` — a read fault REFUSES the pass for that batch - * and reports at `error`; it never answers "already canonical". + * - `normalize-managed-by.ts` refused a read fault for its batch; its + * reading 1 retired with the module (ADR-0131 D3: no boot pass rewrites + * catalog rows any more). * - `auto-org-admin-grant.ts` — a fault on the `sys_member` read that feeds * the revoke branch SKIPS that user for the round, never enters the revoke * branch, and reports at `error`. @@ -57,7 +58,6 @@ import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.j import { reconcileOrgAdminGrant, backfillOrgAdminGrants } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; const SYS = { context: { isSystem: true } } as any; const ORG = 'org_a'; @@ -148,133 +148,6 @@ function recordingLogger() { }; } -// ─────────────────────────────────────────────────────────────────────────── -// Reading 1 — normalize-managed-by.ts `tryFind` (`catch { return []; }`) -// ─────────────────────────────────────────────────────────────────────────── - -describe('[#15840] reading 1 — normalize-managed-by tryFind', () => { - it('a control: a legacy row IS healed, so the harness can tell a real difference', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - const counts = await normalizeManagedByVocab(engine, { logger: logger as any }); - - expect(counts).toEqual({ positions: 1, permissionSets: 0 }); - expect(lines.filter((l) => l.startsWith('info:')).length).toBe(1); - }, 120_000); - - it('[RULED] an unreadable catalog REFUSES the pass; an already-canonical one still answers', async () => { - // ⭐ THE POSITIVE CONTROL — the healthy arm: a catalog that really is - // already canonical. Its behaviour must be EXACTLY what it was before the - // ruling, or this change is option C wearing option A's name. - const healthyEngine = await boot(); - const healthy = recordingLogger(); - const healthyCounts = await normalizeManagedByVocab(healthyEngine, { logger: healthy.logger as any }); - expect(healthyCounts).toEqual({ positions: 0, permissionSets: 0 }); - expect(healthy.lines).toEqual([]); - - // The faulted arm: a catalog holding a row that DOES need healing, behind a - // read that cannot answer. The row is the discriminator — it makes the two - // arms differ in the world, so anything that still reports them alike is - // reporting a state it did not read. - const faultedEngine = await boot(); - await (faultedEngine as any).insert( - 'sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS, - ); - let faultsFired = 0; - const faulted = recordingLogger(); - await expect( - normalizeManagedByVocab( - withFault(faultedEngine, { find: async () => { faultsFired += 1; throw READ_FAULT(); } }), - { logger: faulted.logger as any }, - ), - ).rejects.toThrow(/managed_by normalize REFUSED for sys_position/); - - // ⭐ The refusal aborts at the FIRST un-answered read. Before the ruling - // this pass swallowed four of them (once per legacy value) and reported - // nothing; it now asks once, refuses, and says so once. - expect(faultsFired).toBe(1); - // …and the row it was supposed to heal is untouched. - const stillLegacy = await (faultedEngine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(stillLegacy[0].managed_by).toBe('system'); - - // The value channel separates them: one answers, the other refuses. - // The report channel separates them too — exactly one line, at `error`. - const errors = faulted.lines.filter((l) => l.startsWith('error:')); - expect(errors.length).toBe(1); - expect(errors[0]).toContain('cannot tell "already canonical" from "could not ask"'); - expect(errors[0]).toContain('injected read fault'); - expect(errors[0]).toContain('"healedBeforeRefusal":0'); - // ⛔ And it never claims the counts an already-canonical catalog reports. - expect(faulted.lines.some((l) => l.includes('managed_by vocab normalized'))).toBe(false); - }, 120_000); - - it('[RULED] the refusal reaches the consumer catch that was declared for it', async () => { - // `security-plugin.ts` wraps this call in `try { … } catch { logger.warn( - // '[security] managed_by vocab normalization failed (non-fatal)') }` — the - // only handling any consumer declares, and the reason a refusal is decidable - // at this seam at all. Before the ruling nothing reached it, because the - // fault was swallowed one frame below. Now it does, and boot still proceeds. - const engine = await boot(); - await expect( - normalizeManagedByVocab(withFault(engine, { find: async () => { throw READ_FAULT(); } }), {}), - ).rejects.toThrow(/the catalog read did not answer/); - }, 120_000); - - it('[RULED] a NON-ARRAY answer refuses too — an envelope is not an empty catalog', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - await expect( - normalizeManagedByVocab( - withFault(engine, { find: async (o: string, q?: any, opt?: any) => ({ records: await engine.find(o, q, opt) }) }), - { logger: logger as any }, - ), - ).rejects.toThrow(/did not answer with a row array/); - - expect(lines.filter((l) => l.startsWith('error:')).length).toBe(1); - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('system'); - }, 120_000); - - it('[RULED] rows healed BEFORE the refusal stay healed, and the report says how many', async () => { - // The fault lands on the second object, after the first one healed. A - // refusal is not a rollback, and the `error` line has to say so or an - // operator reads it as "nothing happened". - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - await expect( - normalizeManagedByVocab( - withFault(engine, { - find: async (o: string, q?: any, opt?: any) => { - if (o === 'sys_permission_set') throw READ_FAULT(); - return engine.find(o, q, opt); - }, - }), - { logger: logger as any }, - ), - ).rejects.toThrow(/REFUSED for sys_permission_set/); - - // The position row WAS healed on the way, and stays healed. - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('platform'); - const errors = lines.filter((l) => l.startsWith('error:')); - expect(errors.length).toBe(1); - expect(errors[0]).toContain('the next boot asks again'); - }, 120_000); - - it('⭐ POSITIVE CONTROL — an engine with no find/update is NOT a read fault and still answers', async () => { - // The `!ql` guard is not a read that failed; it is a caller with no engine. - // Byte-identical to before the ruling. - await expect(normalizeManagedByVocab(null as any)).resolves.toEqual({ positions: 0, permissionSets: 0 }); - await expect(normalizeManagedByVocab({} as any)).resolves.toEqual({ positions: 0, permissionSets: 0 }); - }); -}); - // ─────────────────────────────────────────────────────────────────────────── // Reading 2 — auto-org-admin-grant.ts `tryFind` (`catch` reports at `debug`) // ─────────────────────────────────────────────────────────────────────────── diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12449d326a2..fbc0628f1e2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -57,14 +57,12 @@ import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-che import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { createPermissionSetWriteThrough, registerPermissionSetProjection, reconcilePermissionSetProjection, } from './permission-set-projection.js'; -import { runPermissionSetDriftDiagnostics } from './permission-set-drift.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; @@ -78,12 +76,7 @@ import { type SuggestionListFilter, } from './suggested-audience-bindings.js'; import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; -import { - catalogIsPerOrganization, - listSeedOrganizationIds, -} from './organization-scope.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; -import { readDeclaredCapabilityContext } from './declared-capability-context.js'; +import { readDeclared, readDeclaredCapabilityContext } from './declared-capability-context.js'; import { RLSCompiler, RLS_DENY_FILTER, compiledPolicyNameOf, policyDeclaresClause } from './rls-compiler.js'; import { computeTenantLayer0Verdict, @@ -587,12 +580,11 @@ interface CbpRelation { * • `platform` / `package` are managed; `admin`/∅ (tenant-authored) rows are * the admin's. * • sys_position additionally keeps its LEGACY values `system` (→ platform) - * and `config` (→ package) in the managed map: the boot normalizer - * (normalize-managed-by.ts) heals stored rows to the canonical vocabulary, - * but rows written before the normalizer runs — or in a store it has not - * touched yet — must not lose protection in the interim. Dropping the - * legacy keys here is what silently disarmed this gate for sys_position - * after the A4 rename (#2926 ①). + * and `config` (→ package) in the managed map: no boot pass heals stored + * rows to the canonical vocabulary any more (ADR-0131 retired it with the + * catalog seeders), so a row still carrying a legacy value keeps its + * protection here. Dropping the legacy keys is what silently disarmed this + * gate for sys_position after the A4 rename (#2926 ①). * The map value for each managed `managed_by` is the human owner label used in * the (business-message-only) deny text. */ @@ -4398,9 +4390,9 @@ export class SecurityPlugin implements Plugin { ); // Defer platform admin bootstrap until all plugins finish starting — - // sys_user / sys_permission_set objects must be registered (by - // plugin-auth and platform-objects respectively) before we can - // insert seed rows. Falls back to immediate execution when the + // sys_user / sys_user_permission_set objects must be registered (by + // plugin-auth and platform-objects respectively) before the promotion + // can write its grant. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); @@ -4410,81 +4402,19 @@ export class SecurityPlugin implements Plugin { // onMetadataMutation fallback appends listeners, and re-wiring that would // project each save N times. let envProjectionWired = false; + // [#22801 → A] Same once-guard for the `permission` mutation listener + // that re-derives `everyone` (registered in `runBootstrap`). + let everyoneMutationWired = false; // [#3325 / ADR-0092] Union the better-auth managed-object write denies into // the default sets from the LIVE registry, replacing the hand-maintained // baseline as the source of truth (a newly-declared identity table is then // covered without editing a list). This mutates the shared // `bootstrapPermissionSets` instances IN PLACE — the same objects the - // permission evaluator resolves and `bootstrapPlatformAdmin` serializes into - // the seed row — so it must run BEFORE the seeder below, and once (the - // transform is idempotent, but the once-flag avoids re-reading the registry / - // re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. + // permission evaluator resolves — so it runs once, before the bootstrap + // below (the transform is idempotent, but the once-flag avoids re-reading + // the registry / re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. let managedDeniesApplied = false; - // ── The RBAC catalog, materialized PER ORGANIZATION ────────────────── - // - // `sys_position` and `sys_permission_set` both spell their name index - // `unique: 'organization'`, and both were seeded organization-less. On a - // walled deployment that made the whole catalog unreadable to EVERY - // principal at every rung: this plugin's Layer 0 composes a strict - // `organization_id = :tenant` and the middleware ANDs it over the driver's - // `(organization_id = :tenant OR organization_id IS NULL)`, and the - // 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 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. [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. - // [#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 - // remedy ("re-initialize the deployment") that mints them again — on a fresh - // walled deployment the whole line was false and its first branch was a - // loop. Handed over from here because this is where the one array lives: - // the same `bootstrapPermissionSets` goes to the platform bootstrap above - // and onto the manifest as `permissions`, so the two can never disagree. - const platformBucketNames = this.bootstrapPermissionSets - .map((p) => p.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - const seedCatalogPermissions = async (organizationId?: string): Promise => { - try { - await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger, organizationId, platformBucketNames }); - } catch (e) { - ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message, organization: organizationId }); - } - }; - /** - * Every catalog step for ONE organization, in boot order. Used by the - * organization-creation hook; the boot sweep drives the same step - * interleaved with the organization-independent bootstrap work. - */ - const seedCatalogForOrganization = async (organizationId?: string): Promise => { - await seedCatalogPermissions(organizationId); - }; - /** - * Which passes this boot performs: one per organization under a walled - * posture, exactly one organization-less pass under `single`, and NONE when - * the organization enumeration failed (already warned — an unreadable - * `sys_organization` is "we do not know", never "there are no tenants"). - * A walled deployment with zero organizations yet is also none; each - * organization is seeded as it is created. - */ - const catalogSeedPasses = async (): Promise> => { - if (!catalogIsPerOrganization(this.tenancyPosture)) return [undefined]; - const ids = await listSeedOrganizationIds(ql, ctx.logger); - return ids ?? []; - }; - const runBootstrap = async () => { try { if (!managedDeniesApplied) { @@ -4512,79 +4442,33 @@ 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. - const catalogPasses = await catalogSeedPasses(); - // [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 - // objects, and the admin surface finally sees them. Runs AFTER - // bootstrapPlatformAdmin so the platform defaults keep their - // insert-once, provenance-less shape (env config, never clobbered). - for (const organizationId of catalogPasses) { - await seedCatalogPermissions(organizationId); - } - - // [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 - // managed_by:'package' + package_id — the exact provenance the boot - // seeder stamps, only now on the runtime publish path instead of only at - // boot. Idempotent: registerPublishMaterializer replaces on re-run, and - // upsertPackagePermissionSet refuses to clobber env- or foreign-owned - // rows (ADR-0086 D4), so the two doors never overwrite each other. try { const protocol: any = ctx.getService?.('protocol'); - if (protocol && typeof protocol.registerPublishMaterializer === 'function') { - protocol.registerPublishMaterializer( - 'permission', - async (args: { body: unknown; packageId: string | null; organizationId: string | null }) => { - const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger); - // [#10946] `unchanged` counts here. The question this line asks - // is "did the record end up matching the published body", NOT - // "was a write issued" — and until the seeder stopped re-writing - // rows it had nothing to change, those two were accidentally the - // same number. A re-publish of an identical body now lands in - // `unchanged`, and reading only `seeded + updated` would report - // that converged publish as a FAILURE and skip the audience - // reconcile below. The three refusal branches (foreign package, - // env-authored name, no owning package) all leave `unchanged` at - // 0, so every case that reported 0 before still reports 0. - const applied = r.seeded + r.updated + r.unchanged; - // [ADR-0090 D5] A published set carrying the install-time - // suggestion flag surfaces (or retires) its pending - // suggestion row right away — same convergent sync as boot — - // and `everyone` is derived again, so an accepted set whose new - // body fails the derivation stops granting at once. - if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) { - try { - await reconcileAudienceBindingSuggestions(ql, ctx.logger); - } catch (e) { - ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); - } - await this.declareEveryone(ctx.logger); + // [ADR-0090 D5, #22801 → A] Every landed `permission` mutation — + // publish, direct-active save or delete, through any door — runs the + // suggestion reconcile and derives `everyone` again, so an accepted + // set whose new body no longer qualifies (dropped `isDefault`, a + // forbidden bit, deleted) stops granting at once rather than at the + // next boot. Unconditional on the body: whether a set still qualifies + // is `readAcceptedEveryoneSets`' question, asked of what the catalog + // now serves. Drafts change nothing the catalog serves. Wired once; + // the listener is fire-and-forget, so it owns its failures, and + // `declareEveryone` never throws. + if (!everyoneMutationWired && protocol && typeof protocol.onMetadataMutation === 'function') { + protocol.onMetadataMutation((evt: { type?: string; state?: string }) => { + if (evt?.type !== 'permission' || evt.state === 'draft') return; + void (async () => { + try { + await reconcileAudienceBindingSuggestions(ql, ctx.logger); + } catch (e) { + ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // A publish that materialized nothing did NOT go live — report it - // as a failure with the reason so the package-door UI never shows - // a clean publish over a set the admin surface can't see (ADR-0049 - // honesty). The upsert only lands zero rows when it refused: the - // name is owned by another package, owned by the env door, or the - // publish carried no owning package_id to stamp. - if (applied === 0) { - return { - success: false, inserted: 0, updated: 0, - error: r.skippedForeign > 0 - ? 'permission set name is owned by another package' - : r.skippedEnvAuthored > 0 - ? 'permission set name is owned by the environment (edit it through the admin door)' - : 'permission set was not materialized (publish carried no owning package)', - }; - } - return { success: true, inserted: r.seeded, updated: r.updated }; - }, - ); + await this.declareEveryone(ctx.logger); + })(); + }); + everyoneMutationWired = true; } - // [#2747] Uninstall counterpart of the materializer above: when the - // owning package is uninstalled, revoke its data-plane permission + // [#2747] When the owning package is uninstalled, revoke its data-plane permission // rows (package-owned sets + their position/user bindings + the // package's suggestion rows) so grants die with the package — the // "no ghost grants" clause of ADR-0090 D5. @@ -4644,18 +4528,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); } - // [field report — rc→GA declared≠enforced surfacing] Recompute - // "declared ≠ enforced" per package-declared set AFTER both doors - // have settled for this boot (the package door above, and the env - // door's reconciliation just above this line) — so `drift_status` - // reflects the boot's TRUE final state, not a mid-boot snapshot. - // Own try/catch: a failure here must never take down boot, and must - // never be read as "reconciliation also failed" in the log above. - try { - await runPermissionSetDriftDiagnostics(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] permission-set drift diagnostics failed', { error: (e as Error).message }); - } // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce // 式)」, item 3] The DETECTION READING for sets that were already // silently forked before the save door was locked: count + NAMES, @@ -4665,22 +4537,16 @@ export class SecurityPlugin implements Plugin { // cold-boot check holding such a fork does not boot at all; its // remedy is the offline `os migrate security-catalog-overlays`. // - // Distinct from the drift diagnostic above and deliberately run - // beside it: drift reports a set whose grants ALREADY differ from - // the artifact, so an overlay that currently happens to match is - // invisible to it — while that fork is entirely real and freezes - // the set the moment the package ships its next version. - // // Own try/catch, same discipline as its neighbours: a failure here - // must never take down boot, and must never read as "the drift - // diagnostics also failed". + // must never take down boot, and must never read as "the + // reconciliation also failed". try { await reportPackagedPermissionSetOverlays(ql, { logger: ctx.logger }); } catch (e) { ctx.logger.warn('[security] packaged permission-set overlay detection failed', { error: (e as Error).message }); } } catch (e) { - ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); + ctx.logger.warn('[security] permission-set door registration failed', { error: (e as Error).message }); } // [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface: // every declared `isDefault: true` set the `everyone` definition does @@ -4692,15 +4558,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped - // 'user'; older positions stamped system/config/user) onto the unified - // platform/package/admin vocab. Runs after the seeders so their canonical - // writes land first; idempotent and non-fatal. - try { - await normalizeManagedByVocab(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] managed_by vocab normalization failed (non-fatal)', { error: (e as Error).message }); - } bootstrapRanOnce = true; ctx.logger.info('[security] platform bootstrap complete', report); return report; @@ -4718,8 +4575,8 @@ export class SecurityPlugin implements Plugin { // [ADR-0131 D4] Name the permission set on every grant written before // `sys_user_permission_set.permission_set` existed — once per deployment, // recorded in `sys_migration`. At `kernel:bootstrapped`, not `kernel:ready`: - // every `kernel:ready` handler has settled by then (the bootstrap above that - // seeds the catalog rows among them), so the security catalog read each + // every `kernel:ready` handler has settled by then (the bootstrap above + // among them), so the security catalog read each // name is verified through sees every definition this boot registers. A // name it still does not resolve leaves the verdict unrecorded, so the next // boot tries again. See `grant-permission-set-name-backfill.ts`. @@ -4802,45 +4659,6 @@ export class SecurityPlugin implements Plugin { }); } - // ── Project the permission sets of a package that arrives AFTER the boot ── - // - // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The - // declared-permission seeding above runs once, at - // `kernel:ready`, over whatever the engine registry holds by then. A package - // registered later — `os package install` into a running runtime (the - // install-local plugin), an artifact reload — was never projected: its sets - // resolved for the evaluator (`/meta/permission` listed them) while - // `sys_permission_set` had no row, so no admin could grant them until a - // restart re-ran this pass. Every such door announces `metadata:reloaded`, - // the platform's one post-boot re-sync signal (the automation engine - // re-binds flows off the same event), so re-run the SAME step here: the - // same function, the same organization passes, the same provenance rules. - // Idempotent and upgrade-aware by construction (it re-seeds only rows it - // owns and never clobbers env-authored ones), so a reload that changed no - // permission set writes nothing. - // - // Only once the boot's own pass has run: it is what lets - // `bootstrapPlatformAdmin` write the platform defaults first, in their - // insert-once shape, and a reload cannot arrive ahead of it on a real - // kernel anyway. Never throws — `trigger` dispatch PROPAGATES, and a - // subscriber failure must not fail the install or publish that announced. - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('metadata:reloaded', async () => { - if (!bootstrapRanOnce) return; - try { - for (const organizationId of await catalogSeedPasses()) { - await seedCatalogPermissions(organizationId); - } - } catch (e) { - ctx.logger.warn( - '[security] declared permission sets were NOT re-projected after a metadata reload — a package ' + - 'registered after boot has no sys_permission_set row until the next restart', - { error: (e as Error).message }, - ); - } - }); - } - // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── // // Subscribed in `init()`, not here — see {@link claimSeedOwnershipOnSettle}. @@ -4967,80 +4785,12 @@ export class SecurityPlugin implements Plugin { void runOrgAdminBackfill(); } - // ── Seed the RBAC catalog for a NEWLY CREATED organization ────────── - // - // The boot sweep covers the organizations that exist when the kernel is - // ready. Every organization minted afterwards needs the same catalog, and - // waiting for the next restart is not a repair: until it runs, that - // tenant's Setup lists no positions, no permission sets and no sharing - // rules — the exact symptom per-organization materialization exists to fix, - // only scoped to the newest tenant. Steady state therefore rides THIS hook, - // which is also why the boot sweep can afford to be a read-mostly - // reconciliation rather than a blind rewrite. - // - // Walled postures only: under `single` there is no organization to seed - // for, and the one organization-less pass at boot is the correct shape. - // Idempotent by construction (every seeder upserts by - // `(name, organization_id)`), so a double-fire is harmless. - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - await next(); - if (opCtx?.object !== 'sys_organization') return; - const op = opCtx?.operation; - if (op !== 'insert' && op !== 'create') return; - if (!catalogIsPerOrganization(this.tenancyPosture)) return; - const organizationId = this.extractOrganizationIdFromWrite(opCtx); - if (!organizationId) { - // Never fall back to an organization-less pass here. That is the - // `single`-posture shape, and running it on a walled deployment would - // mint exactly the invalid-state row this change exists to remove. - ctx.logger.warn?.( - '[security] an organization was created but its id could not be read off the write — its ' + - 'RBAC catalog was NOT seeded now; the next boot sweep covers it', - { object: 'sys_organization' }, - ); - return; - } - try { - await seedCatalogForOrganization(organizationId); - ctx.logger.info?.('[security] RBAC catalog seeded for a newly created organization', { - organization: organizationId, - }); - } catch (e) { - ctx.logger.warn?.('[security] catalog seeding for a new organization failed (retried on next boot)', { - organization: organizationId, error: (e as Error).message, - }); - } - }); - // Per-organization seed DATA replay on `sys_organization` insert (business // records) lives in `@objectstack/organizations`, along with // `claimOrphanOrgRows` / `cloneOrgSeedData`. Install that plugin for - // multi-tenant deployments. The hook above is a different thing: the RBAC - // CATALOG, which plugin-security owns and seeds from declarations. - } - - /** - * The id of the organization a `sys_organization` insert just created. - * - * Read from the write's own result first and its input second, because the - * two disagree in the case that matters: an insert that lets the engine mint - * the id carries none on the way in. Returns `undefined` rather than guessing - * — the caller refuses to run an organization-less pass on a walled - * deployment, which is the whole point. - */ - private extractOrganizationIdFromWrite(opCtx: any): string | undefined { - const candidates = [ - opCtx?.result?.id, - Array.isArray(opCtx?.result) ? opCtx.result[0]?.id : undefined, - opCtx?.data?.id, - opCtx?.doc?.id, - opCtx?.record?.id, - opCtx?.id, - ]; - for (const c of candidates) { - if (typeof c === 'string' && c !== '') return c; - } - return undefined; + // multi-tenant deployments. The RBAC catalog needs no such replay: this + // plugin writes no catalog row, and every organization reads the same + // registry (ADR-0131 D3). } /** @@ -7058,7 +6808,7 @@ export class SecurityPlugin implements Plugin { * overlay layer. Fails CLOSED and never depends on the caller's grants, so * a platform admin with `modifyAllRecords` cannot forge provenance either. * System/boot writes never reach here (the middleware short-circuits on - * `isSystem`), so the seeder and the publish materializer are unaffected. + * `isSystem`), so system writes are unaffected. */ /** * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index d912893a03e..3a7c2f33cf5 100644 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts @@ -17,8 +17,8 @@ * because the mute was never on the injected arm. * * So case 1 runs a **lit control on the same subject in the same run** — - * `reportCapabilityRowsUnreadable`, an #18091 site that already reaches the - * console with no sink. A zero from this site is a reading only while that + * `reportThroughSink` with no sink, the one derivation every repaired site + * reaches the console through. A zero from this site is a reading only while that * control fires; if the harness ever stops capturing `console.warn`, the * control goes to zero too and the case fails instead of passing vacuously. * @@ -43,7 +43,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildExistingByName } from './seed-name-lookup.js'; -import { reportCapabilityRowsUnreadable } from './seed-refusal-diagnostics.js'; +import { reportThroughSink } from './seed-refusal-sink.js'; /** A driver seam that cannot answer at all — the outage arm. */ function unreadableQl() { @@ -102,15 +102,15 @@ describe('[#18570] batched seed existence read — its own failure is audible wi await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha', 'beta']); // THE LIT CONTROL, same run, same subject (author-visible output with no - // sink injected): an #18091 site that already reaches the console. - reportCapabilityRowsUnreadable(undefined, { unreadable: 2, total: 2 }); + // sink injected): the derivation every repaired site reaches the console through. + reportThroughSink(undefined, '[control] lit control', { unreadable: 2, total: 2 }); }); const lines = seen.map((args) => String(args[0])); // The control fires — so a zero from the site under repair would be a // reading rather than a dead harness. - expect(lines.filter((l) => l.includes('capability_rows_unreadable'))).toHaveLength(1); + expect(lines.filter((l) => l.includes('[control] lit control'))).toHaveLength(1); // And the site under repair is no longer the silent one. Pre-#18570 this // run produced the control line ALONE. diff --git a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts b/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts deleted file mode 100644 index ca6a3fe2a59..00000000000 --- a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18091] The five remaining refusal diagnostics of the two declared-metadata - * seeders — one wording, one token and one record PER SITE. - * - * ## What was wrong - * - * `bootstrapDeclaredCapabilities` and `bootstrapDeclaredPermissions` refuse to - * write in five more places, and every one of those refusals was spelled - * `logger?.warn?.(…)` — optionally chained TWICE, so a caller that injected no - * logger got NO OUTPUT AT ALL. Measured on the pre-fix tree, each site driven - * with no logger while all five console channels were spied: - * - * ```text - * curated platform capability refused skippedPlatform = 1 lines = 0 - * capability declaration unowned skippedUnowned = 1 lines = 0 - * capability rows unreadable unreadable = 1 lines = 0 - * permission set declaration unowned (no counter) lines = 0 - * permission set rows unreadable unreadable = 1 lines = 0 - * LIT CONTROL capability_name_collision skippedForeign = 1 lines = 1 - * LIT CONTROL permission_set_name_… skippedForeign = 1 lines = 1 - * ``` - * - * The two lit controls are the already-repaired axes in the SAME harness, so - * the zeros are a reading rather than an artefact of the probe. - * - * ⛔ Not one of the skips changes here. They are correct under ADR-0086 D4 (a - * package never writes into a foreign record) and ADR-0086 D3 (a row that - * cannot prove its owner makes uninstall undefined). The defect is only that - * the refusal never reached the author who caused it. - * - * ## Why five diagnostics and not one - * - * #18023's delivery established the rule and it is applied here rather than - * re-argued: the token, the record, the wording and the CONSEQUENCE are - * site-specific — four of the six parts a refusal report is made of. The five - * consequences below are genuinely different facts about the deployment: - * - * - a curated-platform-name hijack still RESOLVES (the curated pass owns the - * row) and loses only the declaring package's authored metadata; - * - an unowned CAPABILITY declaration may resolve, may fall back to the - * back-compat derived placeholder, or may exist nowhere at all — three - * outcomes, decided by what already stands in `sys_capability`; - * - an unowned PERMISSION SET keeps every grant working (the evaluator - * resolves declared sets through the metadata registry) and loses only the - * RECORD — the Setup surface, the provenance axis and uninstall; - * - an unreadable read wrote nothing and compared nothing, so nothing is lost - * and nothing arrived either — and what "nothing arrived" costs differs - * again between the two tables. - * - * Wording them into one generic "declaration skipped" sentence would send every - * author of the first kind hunting for a broken grant that is not broken. - * - * ## What IS shared - * - * Exactly one thing: WHERE the line goes. {@link reportThroughSink} carries the - * delivery rule for all five, so this card adds one derivation rather than five - * more copies of the two lines #17516 and #18023 each wrote out by hand. - * - * ## Why `event` and not `code` - * - * Every token below is a snake_case DATA VALUE, not an ADR-0112 error code: it - * is never routed to `error.code`, never reaches a wire refusal, and travels as - * the structured half of a warning about an artifact — the same discrimination - * `CAPABILITY_NAME_COLLISION` and `PERMISSION_SET_NAME_COLLISION` already make. - */ - -import { reportThroughSink, type CollisionReportSink } from './seed-refusal-sink.js'; - -export type { CollisionReportSink }; - -// ─────────────────────────────────────────────────────────────────────────── -// Tokens — one grep string per refusal kind. -// ─────────────────────────────────────────────────────────────────────────── - -/** A package declared a capability whose name is a CURATED platform capability. */ -export const CAPABILITY_PLATFORM_NAME_REFUSED = 'capability_platform_name_refused'; - -/** A declared capability carries no resolvable owning package. */ -export const CAPABILITY_DECLARATION_UNOWNED = 'capability_declaration_unowned'; - -/** Declared capabilities left untouched because `sys_capability` could not be read. */ -export const CAPABILITY_ROWS_UNREADABLE = 'capability_rows_unreadable'; - -/** A declared permission set carries no resolvable owning package. */ -export const PERMISSION_SET_DECLARATION_UNOWNED = 'permission_set_declaration_unowned'; - -/** Declared sets left untouched because `sys_permission_set` could not be read. */ -export const PERMISSION_SET_ROWS_UNREADABLE = 'permission_set_rows_unreadable'; - -/** - * Severity is `warning` on all five for the #4632 reason: this is FUNCTIONAL - * degradation, never a durability failure. Nothing claimed to be persisted - * silently failed to land. - */ -type RefusalSeverity = 'warning'; - -/** The shape every diagnostic in this module shares — ⛔ the wording is not part of it. */ -interface SeedRefusalDiagnosticBase { - readonly severity: RefusalSeverity; - readonly message: string; - readonly fix: string; -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 1 — a package declares a CURATED platform capability name. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused because its name is platform-curated. */ -export interface CapabilityPlatformNameRefusedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_PLATFORM_NAME_REFUSED; - /** The declared capability's name — a member of `PLATFORM_CAPABILITY_NAMES`. */ - readonly name: string; - /** - * The package whose declaration was dropped. `null` when the declaration - * carries no resolvable owner either: this refusal is decided BEFORE the - * owner check, so both faults can be true of one declaration and printing - * `undefined` at an author is not an option. - */ - readonly declaredBy: string | null; - /** The bootstrap permission set(s) granting it — the blast radius, named. */ - readonly grantedBy: readonly string[]; -} - -/** - * Word the curated-name refusal. - * - * ⚠️ The consequence differs from a foreign-owner collision even though both - * end in "the declaration was not applied": the curated pass of - * `bootstrapSystemCapabilities` seeds every curated name UNCONDITIONALLY, so - * the row is there whatever this seeder decides. Nothing is denied. What the - * author loses is the authored label/description/scope and the ADR-0086 D3 - * provenance claim — and the remedy is not "co-own the namespace" (the - * ADR-0130 D1 escape the collision diagnostic offers) but "rename", because a - * curated platform name is never available to a package on any terms. - */ -export function capabilityPlatformNameRefusedDiagnostic(input: { - name: string; - declaredBy?: string | null; - grantedBy?: readonly string[]; -}): CapabilityPlatformNameRefusedDiagnostic { - const declaredBy = input.declaredBy ?? null; - const declarer = declaredBy ?? '(a declaration with no owning package)'; - const grantedBy = [...(input.grantedBy ?? [])]; - const blastRadius = grantedBy.length > 0 - ? `Permission set(s) granting it: ${grantedBy.join(', ')} — those grants keep resolving, ` + - `against the PLATFORM's capability rather than this package's declaration.` - : `No bootstrap permission set grants it, so nothing is denied today — but the declaration ` + - `is still inert.`; - return { - event: CAPABILITY_PLATFORM_NAME_REFUSED, - severity: 'warning', - name: input.name, - declaredBy, - grantedBy, - message: - `[security] [${CAPABILITY_PLATFORM_NAME_REFUSED}] Package ${declarer} declares capability ` + - `"${input.name}", but that name is a CURATED PLATFORM capability. The declaration was NOT ` + - `applied — a package must never redefine a platform-owned capability — so the curated pass ` + - `keeps the sys_capability row, the declaring package's authored label, description and ` + - `scope are not in effect, and the capability carries no package provenance (ADR-0086 D3). ` + - `The name still resolves against the curated row, which is why nothing else reports this. ` + - `${blastRadius}`, - fix: - `Rename the capability in package ${declarer} to a name it owns (prefix it with the ` + - `package's own module prefix). ⛔ A curated platform name is not co-ownable: unlike a ` + - `package-to-package collision (ADR-0130 D1) there is no arrangement under which a package ` + - `may declare it.`, - }; -} - -/** Report one curated-name refusal so it reaches the author with no sink injected. */ -export function reportCapabilityPlatformNameRefused( - logger: CollisionReportSink | undefined, - d: CapabilityPlatformNameRefusedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - declaredBy: d.declaredBy, - grantedBy: [...d.grantedBy], - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 2 — a declared CAPABILITY with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused for want of an owning package. */ -export interface CapabilityDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_DECLARATION_UNOWNED; - readonly name: string; - /** The bootstrap permission set(s) granting it. */ - readonly grantedBy: readonly string[]; - /** Whether a `sys_capability` row already resolves the name — it decides the consequence. */ - readonly hasRow: boolean; -} - -/** - * Word the unowned-capability refusal. - * - * ⚠️ [#4967 Part 3] The three-way consequence below is LOAD-BEARING and is - * preserved verbatim from the sentence this card found at the call site: a - * seeder-side warn that names the capability but not the permission set(s) that - * grant it does not tell the reader what happened. `hasRow` is the reason the - * existence read is taken BEFORE the refusal rather than after it. - */ -export function capabilityDeclarationUnownedDiagnostic(input: { - name: string; - grantedBy?: readonly string[]; - hasRow: boolean; -}): CapabilityDeclarationUnownedDiagnostic { - const grantedBy = [...(input.grantedBy ?? [])]; - const granted = grantedBy.length > 0 - ? `granted by ${grantedBy.join(', ')}` - : 'granted by no bootstrap permission set'; - const consequence = input.hasRow - ? 'an existing sys_capability row already resolves it and is left as-is — the declaration adds no package provenance' - : grantedBy.length > 0 - ? 'falls back to the back-compat derived placeholder — the grant resolves, but with no package provenance (ADR-0086 D3: uninstall undefined)' - : 'nothing derives it either — the capability is materialized nowhere'; - return { - event: CAPABILITY_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - grantedBy, - hasRow: input.hasRow, - message: - `[security] [${CAPABILITY_DECLARATION_UNOWNED}] declared capability "${input.name}" has no ` + - `owning package (${granted}): ${consequence}`, - fix: - `Stamp the declaring package on the capability — the SchemaRegistry's _packageId ` + - `(ADR-0010), or the spec-level packageId (ADR-0086 D3) as the author-declared fallback. ` + - `A package-managed row with no package_id is the ambiguity ADR-0086 D3 removes, which is ` + - `why this seeder writes none.`, - }; -} - -/** Report one unowned-capability refusal so it reaches the author with no sink injected. */ -export function reportCapabilityDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: CapabilityDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message}. Fix: ${d.fix}`, { - event: d.event, - name: d.name, - grantedBy: [...d.grantedBy], - hasRow: d.hasRow, - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 3 — declared capabilities whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the capability pass. - * - * ONE line per pass with the count, never a line per name: a database that is - * down refuses every name at once and a warn each is a flood that buries its - * own meaning. - * - * ⚠️ The consequence has to be SAID. "Unreadable" alone states none, and the - * two halves differ: a genuinely new declaration among these names has no row - * at all, while one whose stored metadata drifted from the shipped declaration - * keeps the stale value. Nothing was written, so nothing is lost — the next - * boot with a readable database does both — but until then the registry does - * not reflect what these packages declare. - * - * ⛔ This one is deliberately NOT exported from the package entry: unlike the - * two refusals an author can cause, an unreadable database is a runtime - * condition no compile-time door can raise, so there is no second consumer by - * construction (the discrimination `position_name_fold_grant` already makes). - */ -export function reportCapabilityRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number }, -): void { - reportThroughSink( - logger, - `[security] [${CAPABILITY_ROWS_UNREADABLE}] declared capabilities left untouched — their ` + - `sys_capability rows could not be read. ${input.unreadable} of ${input.total} declaration(s) ` + - `were neither created nor reconciled: a genuinely new one among them has no sys_capability ` + - `row, and one whose stored label, description or scope has drifted from the shipped ` + - `declaration keeps the stale value. Nothing was written and nothing is lost — the next boot ` + - `with a readable database does both — but until then the registry does not reflect what ` + - `these packages declare.`, - { event: CAPABILITY_ROWS_UNREADABLE, unreadable: input.unreadable, total: input.total }, - ); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 4 — a declared PERMISSION SET with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared permission set refused for want of an owning package. */ -export interface PermissionSetDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof PERMISSION_SET_DECLARATION_UNOWNED; - readonly name: string; - /** The organization whose pass refused it, when the walled per-organization door is in force. */ - readonly organizationId?: string; -} - -/** - * Word the unowned-permission-set refusal. - * - * ⚠️ The consequence is NOT the capability axis'. `stack.permissions` has - * always been runtime-ENFORCED — the evaluator resolves declared sets through - * the metadata registry — so every grant in the refused set keeps working. What - * is lost is the RECORD (ADR-0086 D5, the ADR-0078 inert-metadata smell this - * seeder exists to close): the Setup admin surface reads `sys_permission_set` - * and cannot see the set, there is no provenance axis for it, and uninstall - * (`cleanupPackagePermissions`) has nothing to reap. An author told "not - * materialized" and left to guess will go looking for a denied user who does - * not exist. - */ -export function permissionSetDeclarationUnownedDiagnostic(input: { - name: string; - organizationId?: string; -}): PermissionSetDeclarationUnownedDiagnostic { - return { - event: PERMISSION_SET_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - ...(input.organizationId ? { organizationId: input.organizationId } : {}), - message: - `[security] [${PERMISSION_SET_DECLARATION_UNOWNED}] declared permission set ` + - `"${input.name}" has no owning package — not materialized. A managed_by:'package' row ` + - `without a package_id is the ambiguity ADR-0086 D3 removes, so no row is written. The ` + - `evaluator still resolves the DECLARED set through the metadata registry, so its object, ` + - `field, tab and system permissions all keep working — what is missing is the RECORD: the ` + - `Setup admin surface reads sys_permission_set and cannot see this set, it carries no ` + - `provenance axis, and uninstall has nothing to reap (ADR-0086 D5).`, - fix: - `Stamp the declaring package on the set — the SchemaRegistry's _packageId (ADR-0010), or ` + - `the spec-level packageId (ADR-0086 D3) as the author-declared fallback.`, - }; -} - -/** Report one unowned-set refusal so it reaches the author with no sink injected. */ -export function reportPermissionSetDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: PermissionSetDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - ...(d.organizationId ? { organization: d.organizationId } : {}), - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 5 — declared permission sets whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the permission-set pass. - * - * ⚠️ Said once with the count, and the consequence spelled out for the SAME - * reason as the capability summary and with a DIFFERENT content: silence here - * reads exactly like "everything was already in order". The declared sets stay - * runtime-enforced through the metadata registry, so nothing is denied — what - * did not happen is the materialization, and a drifted row keeps its stale - * grants until a boot that can read the table. - * - * ⛔ Package-private for the same reason as its capability sibling: an - * unreadable database is a runtime condition, not an authoring fault. - */ -export function reportPermissionSetRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number; organizationId?: string }, -): void { - reportThroughSink( - logger, - `[security] [${PERMISSION_SET_ROWS_UNREADABLE}] declared permission sets left untouched — ` + - `their records could not be read. ${input.unreadable} of ${input.total} declared set(s) ` + - `were neither seeded nor reconciled: a genuinely new set has no sys_permission_set row, so ` + - `the Setup admin surface cannot see it and uninstall has nothing to reap (ADR-0086 D5), and ` + - `a set whose stored grants drifted from the shipped declaration keeps the stale row. The ` + - `evaluator still resolves the DECLARED sets through the metadata registry, so no grant is ` + - `denied today; the next boot with a readable database materializes them.`, - { - event: PERMISSION_SET_ROWS_UNREADABLE, - unreadable: input.unreadable, - total: input.total, - ...(input.organizationId ? { organization: input.organizationId } : {}), - }, - ); -} diff --git a/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts b/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts deleted file mode 100644 index 45af1d0dfcf..00000000000 --- a/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#11532] A FRESH walled deployment mints the PLATFORM BUCKET - * organization-less — and the per-organization pass must stop calling the - * platform's own output a "pre-fix leftover" with a remedy that recreates it. - * - * ## What was measured on a real deployment - * - * A fresh boot of the walled HotCRM SaaS rig (`OS_TENANCY_POSTURE=isolated`, - * three organizations, one SQLite file) stored 50 `sys_permission_set` rows: - * 14 x 3 organizations, plus **8 organization-less** ones carrying - * `managed_by = 'platform'` / `package_id = NULL`, written 1.3 s BEFORE the - * deployment's first `sys_organization` row existed. `bootstrapPlatformAdmin` - * wrote them — it is the fifth seeder, outside the four #11121 converted, and - * its own log line names the count (`{"seeded":8,...}`). - * - * ## What is NOT in scope here, and why the minting is left alone - * - * ⛔ Routing that seeding through the per-organization pass is **ruled out of - * this landing**. #10103's maintainer ruling of 2026-08-20 (Q1/Q2, live - * decision-inbox session) closes it verbatim: - * - * > Q1's platform-defaults residue (`bootstrapPlatformAdmin`'s three sets, - * > the env-door projection) stays outside this card, unreaped and loudly - * > warned about under walled posture; whether customers ever need those - * > visible is a future small decision card, filed only when an onboarding - * > flow actually wants it. - * - * The rows are also load-bearing: PLATFORM_ADMIN is DERIVED from an unscoped - * `sys_user_permission_set` grant pointing at the organization-less - * `admin_full_access` row BY ROW ID (`resolve-authz-context.ts` section 6b), - * which is the same reason the ruled Option C refused a #8617-breadth reap. - * - * So section 1 pins the minting as CURRENT, RULED behaviour — by row identity, - * not by count, because two offsetting errors hold a count constant while the - * identity inverts. What this card repairs is section 3: the diagnostic. - * - * ## Why the diagnostic could not be measured with the diagnostic - * - * The pass's own verdict is self-falsifying — it reports the platform's output - * of 1.3 s earlier as legacy state the operator never had, and offers - * "re-initialize the deployment", which mints exactly these rows again on the - * next boot. Its output therefore cannot serve as evidence for anything, - * including that this fix worked. Every assertion below reads STORED ROWS off - * knex (past every engine-side projection) or the logger's recorded calls — - * never the pass's summary. - * - * ## Why a real engine and a real driver - * - * The defect lives where a scope the ENGINE threads meets a predicate the - * DRIVER emits, exactly as in `per-organization-catalog.test.ts`. A hand-built - * engine double implements neither, so these cases run a real `SqlDriver` on - * better-sqlite3 `:memory:` behind a real `ObjectQL`, drive the SHIPPED - * seeders, and seed from the SHIPPED `defaultPermissionSets` — the same array - * `security-plugin.ts` hands to `bootstrapPlatformAdmin` and puts on the - * manifest as `permissions`. - */ - -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; - -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; - -const ORGS = ['org_jia', 'org_yi', 'org_bing'] as const; -const SECURITY_PACKAGE = 'com.objectstack.plugin-security'; - -/** The platform bucket's names, read off the SHIPPED declaration. */ -const PLATFORM_BUCKET_NAMES = defaultPermissionSets.map((ps) => ps.name).sort(); - -/** - * One extra declared set that the platform bootstrap does NOT seed. It is the - * positive control for the OTHER class: a genuine pre-fix organization-less - * row, for which "re-initialize the deployment" remains the correct remedy and - * must keep being offered. - */ -const ACME_SET = { name: 'acme_readonly', label: 'Acme RO', _packageId: 'com.acme.crm', objects: {} }; - -/** - * What `manifest.register({ permissions: this.bootstrapPermissionSets })` - * produces: the platform bucket's own declarations, provenance-stamped for - * plugin-security, plus one foreign package's set. - */ -const DECLARED_PERMISSIONS = [ - ...defaultPermissionSets.map((ps) => ({ ...(ps as any), _packageId: SECURITY_PACKAGE })), - ACME_SET, -]; - -function recordingLogger() { - const warns: Array<{ message: string; meta: any }> = []; - const infos: Array<{ message: string; meta: any }> = []; - return { - warns, - infos, - logger: { - info: (message: string, meta?: any) => { infos.push({ message, meta }); }, - warn: (message: string, meta?: any) => { warns.push({ message, meta }); }, - error: (_m: string, _meta?: any) => { /* the walled "ZERO config-derived platform administrators" backstop — over CONFIGURATION, not elevation; not this card's subject */ }, - }, - }; -} - -const engines: ObjectQL[] = []; -let posture: string | undefined; - -beforeEach(() => { - posture = process.env.OS_TENANCY_POSTURE; - process.env.OS_TENANCY_POSTURE = 'isolated'; -}); - -afterEach(async () => { - if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = posture; - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.walled-platform-bucket-11532', - name: 'Walled platform bucket', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysOrganization, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The delegating seam `per-organization-catalog.test.ts` uses: every verb - * reaches the real ObjectQL, `update` opens with the PRODUCER's own dispatch - * predicate, and `registry` answers `readDeclared`. - */ -function withRegistry(engine: any, declared: any[] = DECLARED_PERMISSIONS): any { - return { - find: (o: string, q?: any, opt?: any) => engine.find(o, q, opt), - insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), - update: (o: string, d: any, opt?: any) => { - assertEngineUpdateDispatch(d, opt); - return engine.update(o, d, opt); - }, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - }; -} - -/** Ground truth: every stored row, straight off knex, past all tenancy. */ -async function stored(engine: ObjectQL, table = 'sys_permission_set'): Promise { - const driver: any = (engine as any).getDriver(table); - return driver.knex(table).select('*'); -} - -const orgOf = (r: any): string | null => (r.organization_id ?? null); -const orgLess = (rows: any[]) => rows.filter((r) => orgOf(r) === null); - -/** The boot order `security-plugin.ts` runs: platform bootstrap, then one pass per organization. */ -async function walledBoot(engine: ObjectQL, logger: any): Promise<{ seeded: number }> { - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - for (const org of ORGS) { - await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - } - return { seeded: report.seeded }; -} - -/** The one warning class this card is about, whatever wording it ends up carrying. */ -const bucketWarns = (warns: Array<{ message: string; meta: any }>) => - warns.filter((w) => (w.meta?.names ?? []).some((n: string) => PLATFORM_BUCKET_NAMES.includes(n))); - -describe('[#11532] the walled platform bucket and the diagnostic that describes it', () => { - it('1. PREMISE (ruled, unchanged): a fresh walled boot mints the platform bucket organization-less — pinned by ROW IDENTITY, not count', async () => { - const engine = await boot(); - const { logger, infos } = recordingLogger(); - - const { seeded } = await walledBoot(engine, logger); - const rows = await stored(engine); - const bucket = orgLess(rows); - - // (a) IDENTITY, name by name — a count would hold constant under two - // offsetting errors while the membership inverted. - expect(bucket.map((r) => r.name).sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(seeded).toBe(PLATFORM_BUCKET_NAMES.length); - // The eight names the rig measured are named literally, so a silent change - // to the shipped declaration cannot quietly redefine what this pins. - expect(PLATFORM_BUCKET_NAMES).toEqual([ - 'admin_full_access', - 'mcp_agent_data_read', - 'mcp_agent_data_write', - 'mcp_agent_restricted', - 'member_default', - 'organization_admin', - 'organization_admin_no_bypass', - 'viewer_readonly', - ]); - - // (b) The provenance the rig measured: platform-owned, no package. - for (const row of bucket) { - expect(row.managed_by).toBe('platform'); - expect(row.package_id ?? null).toBeNull(); - } - - // (c) Every organization still holds its OWN complete copy, package-owned — - // which is why nothing is broken for a reader and why the bucket is not - // reaped. Identity again: the per-organization row is a DIFFERENT row id. - const bucketIds = new Set(bucket.map((r) => r.id)); - for (const org of ORGS) { - const own = rows.filter((r) => orgOf(r) === org); - expect(own.map((r) => r.name).sort()).toEqual([...PLATFORM_BUCKET_NAMES, ACME_SET.name].sort()); - for (const r of own) { - expect(bucketIds.has(r.id)).toBe(false); - expect(r.managed_by).toBe('package'); - } - expect(own.filter((r) => r.name === 'admin_full_access')[0].package_id).toBe(SECURITY_PACKAGE); - } - - // (d) The PRODUCER says what it wrote. The rig's boot log read - // `{"seeded":8}` with nothing to indicate the rows carried no organization, - // so the operator's first sight of them was a warning calling them legacy - // state. Under a walled posture the seeder now names the bucket itself. - const declared = infos.filter((i) => i.message.includes('the platform bucket')); - expect(declared).toHaveLength(1); - expect([...(declared[0].meta?.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(declared[0].meta?.seeded).toBe(PLATFORM_BUCKET_NAMES.length); - }); - - it('2. the per-organization sweep leaves the platform bucket ROWS untouched — same ids, same values', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - const before = JSON.stringify(orgLess(await stored(engine)).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - for (const org of ORGS) await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - const after = JSON.stringify(orgLess(await stored(engine)).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - // Byte-equal: not reaped, not adopted, not re-stamped. The bucket is exactly - // what the ruling said it would be, and its ids are still what the unscoped - // PLATFORM_ADMIN grant points at. - expect(after).toBe(before); - }); - - it('3. THE FIX: the platform bucket is NOT reported as a pre-fix leftover, and its remedy is not the loop', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - await walledBoot(engine, logger); - - const guard = bucketWarns(warns); - // Once per organization — the guard is per pass, and silence would be the - // OTHER failure (the ruling requires these rows be warned about loudly). - expect(guard).toHaveLength(ORGS.length); - - for (const org of ORGS) { - const forOrg = guard.filter((w) => w.meta?.organization === org); - expect(forOrg).toHaveLength(1); - const [w] = forOrg; - // (a) Every platform-bucket name is named — loudly, by name, as ruled. - expect([...(w.meta.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(w.meta.count).toBe(PLATFORM_BUCKET_NAMES.length); - - // (b) NOT pre-fix. On a fresh deployment there are no pre-fix rows; these - // were minted by this same boot, seconds earlier. Asserted on the - // MACHINE-READABLE classification rather than on prose, so a reworded - // message cannot quietly re-merge the two classes. - expect(w.meta.origin).toBe('platform-bucket'); - expect(w.message).toContain('PLATFORM BUCKET'); - expect(w.message).toContain('on every boot'); - expect(w.message).not.toContain('pre-fix organization-less'); - - // (c) The remedy is not the loop. "Remedy: re-initialize the deployment" - // recreates exactly these rows on the next boot, so it is not offered. - expect(w.message).not.toContain('Remedy: re-initialize the deployment'); - expect(w.message).toContain('Re-initializing the deployment does NOT clear'); - - // (d) …and the reader is told the thing that actually matters: their own - // catalog is complete, so no action is required. - expect(w.message).toContain("own copies WERE created"); - expect(w.message).toContain('no action is required'); - } - }); - - it('4. POSITIVE CONTROL: a genuine pre-fix organization-less row still gets the original wording AND the re-initialize remedy', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - // A pre-fix deployment: ONE organization-less pass through the SHIPPED - // seeder, the pre-#11121 behaviour, carrying only the foreign package's - // set. `acme_readonly` is not in the platform bucket, so its leftover is - // the real thing — a re-initialized deployment really would not have it. - await bootstrapDeclaredPermissions(withRegistry(engine, [ACME_SET]), null, { logger }); - expect(orgLess(await stored(engine)).map((r) => r.name)).toEqual([ACME_SET.name]); - - warns.length = 0; - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - for (const org of ORGS) await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - - const preFix = warns.filter((w) => (w.meta?.names ?? []).includes(ACME_SET.name)); - expect(preFix).toHaveLength(ORGS.length); - for (const w of preFix) { - expect(w.meta.origin).toBe('pre-fix-residue'); - expect(w.message).toContain('pre-fix organization-less'); - expect(w.message).toContain('re-initialize the deployment'); - expect(w.message).toContain('adopt'); - expect(w.message).toContain('NOT deleted'); - // The two classes are reported SEPARATELY: a pre-fix warning that also - // carried the platform bucket's names would hand the operator a remedy - // that is a loop for most of the list it prints. - expect([...(w.meta.names ?? [])]).toEqual([ACME_SET.name]); - } - }); - - it('5. `single` posture is untouched: organization-less is the correct shape there, and nothing is warned', async () => { - const engine = await boot(); - const { logger, warns, infos } = recordingLogger(); - const ql = withRegistry(engine); - process.env.OS_TENANCY_POSTURE = 'single'; - - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - await bootstrapDeclaredPermissions(ql, null, { logger }); - - const rows = await stored(engine); - expect(rows.every((r) => orgOf(r) === null)).toBe(true); - expect(warns.filter((w) => (w.meta?.names ?? []).length > 0)).toEqual([]); - // …and the producer's walled-only declaration stays silent here: an - // organization-less row is the CORRECT shape under `single`. - expect(infos.filter((i) => i.message.includes('the platform bucket'))).toEqual([]); - }); - - it('6. a host that OVERRODE the default sets is classified against ITS array, not the shipped one', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - // The counterfactual `SecurityPluginOptions.defaultPermissionSets` creates: - // the organization-less writer seeds a DIFFERENT list, so `acme_readonly` - // becomes the re-minted name and the shipped eight become genuine leftovers. - await bootstrapDeclaredPermissions(withRegistry(engine, [ACME_SET]), null, { logger }); - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - - warns.length = 0; - for (const org of ORGS) { - await bootstrapDeclaredPermissions(ql, null, { - logger, - organizationId: org, - platformBucketNames: [ACME_SET.name], - }); - } - - const bucket = warns.filter((w) => w.meta?.origin === 'platform-bucket'); - const preFix = warns.filter((w) => w.meta?.origin === 'pre-fix-residue'); - expect(bucket).toHaveLength(ORGS.length); - expect(preFix).toHaveLength(ORGS.length); - for (const w of bucket) expect([...(w.meta.names ?? [])]).toEqual([ACME_SET.name]); - // Positive control on the same run: the shipped eight are NOT silently - // swallowed — they move to the other class rather than disappearing. - for (const w of preFix) expect([...(w.meta.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - }); -}); From 77ad5f2963cd2ddd09bc936b8306700fe6cc66e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:07:59 +0000 Subject: [PATCH 02/25] wip(plugin-security,cli): the platform-admin bootstrap writes no catalog row; os meta resync is retired (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- content/docs/data-modeling/drivers.mdx | 2 +- .../meta/resync-skip-explanation.test.ts | 80 ---- packages/cli/src/commands/meta/resync.ts | 243 ------------ packages/cli/src/utils/format.ts | 2 +- ...form-migrations-arming.integration.test.ts | 2 +- ...te.dev-self-heal-fence.integration.test.ts | 4 +- .../schema-migrate.lazy-driver-import.test.ts | 2 +- ...igrate.one-shot-family.integration.test.ts | 8 +- packages/cli/src/utils/schema-migrate.ts | 12 +- .../cli/test/json-stdout-purity.e2e.test.ts | 1 - packages/drivers/driver-sql/src/sql-driver.ts | 2 +- ...tform-admin-refused-existence-read.test.ts | 67 ++-- ...p-platform-admin-seeded-provenance.test.ts | 369 ------------------ .../src/bootstrap-platform-admin.test.ts | 143 ------- .../src/bootstrap-platform-admin.ts | 318 +++------------ .../src/claim-seed-ownership.ts | 2 +- .../src/permission-set-projection.ts | 2 +- packages/runtime/src/standalone-stack.ts | 2 +- .../references/operations.md | 2 +- 19 files changed, 94 insertions(+), 1169 deletions(-) delete mode 100644 packages/cli/src/commands/meta/resync-skip-explanation.test.ts delete mode 100644 packages/cli/src/commands/meta/resync.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5065db485e9..3021dfe3ccd 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -735,7 +735,7 @@ A file-backed SQLite database is switched to **WAL** (write-ahead logging) the first time ObjectStack connects to it. SQLite's own default is a rollback journal (`journal_mode = delete`), which is the wrong trade for the way this platform is actually used — **several processes on one file**: a dev server, -`os migrate`, `os meta resync`, a test run. +`os migrate`, a test run. | | rollback journal (SQLite default) | WAL (ObjectStack default) | |:---|:---|:---| diff --git a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts b/packages/cli/src/commands/meta/resync-skip-explanation.test.ts deleted file mode 100644 index 6408df66492..00000000000 --- a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #9184 — `os meta resync` reports a nonzero skip count with no explanation. -// -// The docblock half (#9130 / PR #9183) already explains, IN SOURCE, that a -// stored `'admin'` (or legacy `'user'`) stamp is not always a deliberate -// Setup takeover — on any install created before commit 712e185db the platform's own -// seeder wrote that exact stamp, so `resynced 0 / skipped N` is a permanent, -// by-design outcome there. This card is the runtime-output half: the same -// explanation has to reach the operator who is staring at the terminal, -// never having gone looking for the source. -// -// What is pinned here: -// • the explanation fires exactly when `resyncSkipped > 0` — the same -// condition the skip-count summary itself uses, never a narrower one -// (e.g. `resynced === 0`); -// • it names BOTH spellings of the legacy-vs-current provenance value -// (`admin` and `user`), because a stored legacy row can surface under -// either; -// • it says the skip is expected / by design / not a failure — the whole -// point of the card; -// • it does NOT reintroduce "intentional override" framing for the -// admin-owned case — the docblock removed that as false (before commit 712e185db the -// seeder wrote the stamp, not an admin), and this line must not -// contradict it. - -import { describe, it, expect } from 'vitest'; -import { resyncSkipExplanationLine } from './resync.js'; - -describe('resyncSkipExplanationLine — the runtime-output half of #9184', () => { - it('is silent when nothing was skipped', () => { - expect(resyncSkipExplanationLine(0)).toBeNull(); - }); - - it('is silent for a negative count too (defensive — should never occur)', () => { - expect(resyncSkipExplanationLine(-1)).toBeNull(); - }); - - it('fires for a full skip (the classic pre-#8692 "resynced 0 / skipped N" case)', () => { - const line = resyncSkipExplanationLine(8); - expect(line).not.toBeNull(); - expect(line).toContain('admin'); - expect(line).toContain('user'); - expect(line).toContain("before the seeder began stamping its default sets 'platform'"); - }); - - it('fires identically for a PARTIAL skip — same trigger as the summary, not `resynced === 0`', () => { - // The card requires this line to ride the same condition as the skip - // summary (`resyncSkipped > 0`), not a narrower "totally stuck" gate. A - // caller with resynced=3/skipped=2 must get the same explanation as - // resynced=0/skipped=8. - expect(resyncSkipExplanationLine(2)).toBe(resyncSkipExplanationLine(8)); - }); - - it('says the outcome is expected / by design / not a failure', () => { - const line = resyncSkipExplanationLine(1)!; - expect(line.toLowerCase()).toMatch(/expected|by design/); - expect(line.toLowerCase()).toContain('not a failure'); - }); - - it('never claims the admin-owned case is a deliberate override — the framing PR #9183 removed', () => { - const line = resyncSkipExplanationLine(1)!; - // The case predating commit 712e185db is exactly the platform's own seeder inheriting a - // field default, not an administrator deciding anything — the docblock - // in bootstrap-platform-admin.ts is explicit that stating this as - // "(intentional override)" is a lie for those rows. This line must keep - // that same neutral framing rather than reintroducing it. - expect(line).toContain("isn't always a deliberate Setup takeover"); - expect(line).not.toMatch(/admin[^.]*\(intentional override\)/i); - }); - - it('keeps the package-owned case correctly framed as ALWAYS deliberate, unlike admin', () => { - // Unlike a stored 'admin'/'user' stamp, a package-owned row genuinely is - // always a deliberate override — the docblock draws this distinction and - // the runtime line must not blur it by applying uniform "override" - // language to both provenance classes. - const line = resyncSkipExplanationLine(1)!; - expect(line).toMatch(/package-owned row.*always a deliberate override/s); - }); -}); diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts deleted file mode 100644 index b1c92228da8..00000000000 --- a/packages/cli/src/commands/meta/resync.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { Command, Flags } from '@oclif/core'; -import chalk from 'chalk'; -import { createInterface } from 'node:readline'; -import { - printHeader, - printSuccess, - printWarning, - printError, - printInfo, - printStep, - createTimer, - emitJson, - errorCodeFields, -} from '../../utils/format.js'; -import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; - -async function confirm(question: string): Promise { - if (!process.stdin.isTTY) return false; // non-interactive → require --yes - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer: string = await new Promise((resolve) => rl.question(question, resolve)); - return /^y(es)?$/i.test(answer.trim()); - } finally { - rl.close(); - } -} - -function safeGetService(kernel: any, name: string): any { - try { - return kernel?.getService?.(name); - } catch { - return undefined; - } -} - -/** - * The dim explanatory line printed under the skip-summary `printWarning` - * when `resyncSkipped > 0` — the runtime-output half of #9184 (the docblock - * half, #9130/PR #9183, already explains this in source). - * - * `null` when nothing was skipped, so the caller's `if (resyncSkipped > 0)` - * gate is the single source of truth for the trigger condition: this line - * rides the SAME condition as the skip-count summary it sits under, never a - * separate one (e.g. `resynced === 0`) — a partial skip deserves the same - * explanation as a total one, and "alongside the skip summary" in the issue - * means exactly that pairing. - * - * Deliberately neutral about the skipped row's OWN provenance, matching the - * seeder docblock (`bootstrap-platform-admin.ts`) this line must not - * contradict: the skip decision is unconditionally intentional (only - * platform-owned rows are ever reconciled), but a stored `'admin'` stamp is - * NOT always a deliberate Setup takeover — on any install predating commit 712e185db - * the platform's own seeder wrote that exact stamp, so `resynced 0 / skipped - * N` is a permanent, by-design outcome there rather than a sign the command - * failed. `'user'` is named too: it is the legacy spelling of the same - * class, healed to `'admin'` by `normalizeManagedByVocab`, so a stored - * legacy row can still surface here under either spelling. - */ -export function resyncSkipExplanationLine(resyncSkipped: number): string | null { - if (resyncSkipped <= 0) return null; - // One unwrapped sentence per concatenated piece — never split by an - // embedded '\n' — so a key phrase can never straddle a hard line break - // (the terminal soft-wraps on its own, same as the file's other dim - // follow-up lines a few lines up). - return ( - " Expected, not a failure — resync only reconciles platform-owned rows. " + - "A stored 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup takeover: " + - "on installs created before the seeder began stamping its default sets 'platform', the platform's own " + - "seeded defaults carry that same stamp, so a " + - "persistent skip count here can be permanent by design. A package-owned row, by contrast, is " + - "always a deliberate override by the package that owns it." - ); -} - -/** - * `os meta resync` — reconcile materialized metadata to the compiled `dist` - * without a `--fresh` wipe (#2705). - * - * The default permission sets (admin_full_access / member_default / - * viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never - * clobbered, so an edit to a default set's source is served with its OLD value - * until the DB is wiped — a silent dev-loop trap (a permission-gated action - * "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared - * permission sets, positions, built-in roles, capabilities) already upserts on - * boot; this command closes the one remaining gap by force-reconciling the - * default permission-set rows to the shipped declaration. - * - * Business data is never touched — only `sys_permission_set` definition rows, - * and only rows still `managed_by:'platform'` are reconciled. A row a package - * owns (`'package'`) is left alone as a deliberate override. A row stamped - * `'admin'` — or the legacy spelling `'user'`, healed to `'admin'` by the - * boot-time vocabulary normalizer (`normalizeManagedByVocab`) — is left alone - * too, but is NOT always a deliberate override: on any install created before - * commit 712e185db (the 2026-08-15 ruling) the platform's OWN seeded default sets carry that same - * `'admin'` stamp, indistinguishable from a genuine Setup takeover, so - * `resynced 0 / skipped N` is a permanent, by-design outcome on those - * installs rather than a bug. - */ -export default class MetaResync extends Command { - static override description = - 'Reconcile materialized metadata (default permission sets, which boot seeds insert-once) to the compiled dist without a --fresh wipe'; - - static override examples = [ - '$ os meta resync', - '$ os meta resync --yes', - '$ os meta resync --json', - ]; - - static override flags = { - 'database-url': Flags.string({ - description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)', - env: 'OS_DATABASE_URL', - }), - yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }), - json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }), - }; - - async run(): Promise { - const { flags } = await this.parse(MetaResync); - const timer = createTimer(); - let exitCode = 0; - - if (!flags.json) { - printHeader('Meta · resync'); - printStep('Booting runtime stack…'); - } - - // [#21391] A run that can never reach the write — no `--yes`, and nobody - // at a terminal to confirm (`--json`, or stdin not a TTY) — answers - // `confirmation_required` and writes nothing, so it boots READ-ONLY, the - // boot `os migrate plan` takes. A run that may write keeps the plain boot: - // `sys_permission_set` must exist before the resync writes into it, and - // the interactive prompt comes after the boot. - const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true); - - let stack; - try { - stack = await bootSchemaStack({ - jsonOutput: flags.json, - databaseUrl: flags['database-url'], - ...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), - }); - } catch (error: any) { - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - process.exit(1); - } - - try { - const ql = safeGetService(stack.kernel, 'objectql'); - // Prefer the SecurityPlugin's resolved set when a full stack is booted (it - // picks up an app's `defaultPermissionSets` override); fall back to the - // platform defaults, which is what the standalone stack this command boots - // exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds. - const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets'); - const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets; - - if (!ql) { - if (flags.json) { - await emitJson({ error: 'objectql_unavailable' }, 0, { compact: true }); - return; - } - printError('ObjectQL service is not available — cannot resync.'); - return; - } - if (!Array.isArray(sets) || sets.length === 0) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }, 0, { compact: true }); - return; - } - printWarning('No default permission sets are available to resync.'); - return; - } - - // Confirmation gate — resync overwrites the default permission-set - // definitions from the compiled dist, so admin Setup edits to those sets - // are replaced. Business data is never touched. - if (!flags.yes) { - if (flags.json || !process.stdin.isTTY) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); - return; - } - printWarning('Confirmation required. Re-run with --yes to resync.'); - return; - } - printInfo(`Database: ${chalk.white(stack.dbLabel)}`); - printWarning('This overwrites the default permission-set definitions from the compiled dist.'); - console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.')); - console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.')); - const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `)); - if (!ok) { - printInfo('Aborted — no changes made.'); - return; - } - } - - const logger = flags.json - ? undefined - : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; - - const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger }); - const resynced = report.resynced ?? 0; - const resyncSkipped = report.resyncSkipped ?? 0; - // seeded = existing + newly inserted; existing (under resync) = resynced + - // resyncSkipped, so the remainder is what this run freshly seeded. - const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); - - if (flags.json) { - await emitJson({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }); - return; - } - - console.log(''); - if (resynced > 0 || inserted > 0) { - printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`); - } else { - printInfo('Nothing reconciled.'); - } - if (resyncSkipped > 0) { - printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned).`); - const note = resyncSkipExplanationLine(resyncSkipped); - if (note) console.log(chalk.dim(note)); - } - console.log(chalk.dim(` ${timer.display()}`)); - console.log(''); - } catch (error: any) { - exitCode = 1; - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - } finally { - await stack.shutdown(); - // A one-shot command must exit even when the booted app stack left - // keep-alive handles open (schedulers/watchers registered on kernel:ready - // that `runtime.stop()` cannot fully drain). Matches the process.exit - // posture the other one-shot CLI commands use. - process.exit(exitCode); - } - } -} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 5804f510bee..60b6aa464bb 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -45,7 +45,7 @@ export const CLI_ALIAS = 'os'; * * Every other `--json` site in this CLI reports its duration INSIDE the * payload (`{ ...report, duration: timer.elapsed() }` — `os lint`, - * `os migrate meta`, `os migrate summary-nulls`, `os meta resync`), which is + * `os migrate meta`, `os migrate summary-nulls`), which is * what those two meant to do as well. With this union a duration in the exit * slot is a compile error, so the mistake cannot be made silently again. * diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index 55fc8398bea..1681e61b4a9 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -322,7 +322,7 @@ describe('#9380 the kernel:ready platform migrations, on the boots that reach th // WRITE modes boot without `deferSchemaDdl` — `--apply` of `os migrate // meta --stored`, `summary-nulls`, `value-shapes`, `recorded-by`, // `files-to-references` and `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes`. (Every + // --run`, `os secret orphans --delete`. (Every // no-write mode boots deferred since #21391.) Each writes only what the // operator confirmed; if the arming were gated on deferral instead of on // being a one-shot boot, every one of them would also repair rows the diff --git a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts index a935878def5..9bd08f8a6fe 100644 --- a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts @@ -11,8 +11,8 @@ * one-shot command boot under `NODE_ENV=development` would otherwise inherit * the self-heal: * - * - a NON-deferred one-shot boot (every write mode: `os meta resync --yes`, - * the data commands' `--apply`) runs boot schema sync, so it would apply + * - a NON-deferred one-shot boot (every write mode: the data commands' + * `--apply`) runs boot schema sync, so it would apply * safe drift its operator never saw in a preview; * - `os migrate plan` defers its DDL (#3917), so its own boot never reaches * the reconcile — this file pins that it stays write-free on the card's diff --git a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts index 62794d8f06a..9484e911919 100644 --- a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts +++ b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts @@ -7,7 +7,7 @@ * oclif's `findCommand` walks the command table and `import()`s every command * module on **every** CLI invocation, so a broken import chain anywhere in that * table is charged to whatever command you actually ran. `schema-migrate.ts` is - * shared by nine commands (`meta:resync`, `migrate`, and seven `migrate:*`), and + * was shared by nine commands (then `meta:resync`, `migrate`, and seven `migrate:*`), and * its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'` * meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print * nine `MODULE_NOT_FOUND` blocks (eighteen — `dev` forks a child) naming nine diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index adfba40bdd5..6dd09036b82 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -54,7 +54,6 @@ import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; -import MetaResync from '../commands/meta/resync.js'; import MigrateAccountIssuer from '../commands/migrate/account-issuer.js'; import MigrateApply from '../commands/migrate/apply.js'; import MigrateAuditMetadataBodies from '../commands/migrate/audit-metadata-bodies.js'; @@ -146,11 +145,6 @@ const invoke = (command: { run: (argv: string[], opts: { root: string }) => Prom * modes it has. The first case below holds this table equal to the source. */ const CALLERS: Record = { - 'commands/meta/resync.ts': { - run: invoke(MetaResync), - noWrite: [{ label: 'meta resync (no --yes)', argv: ['--database-url', '@DB@', '--json'] }], - write: [{ label: 'meta resync --yes', argv: ['--yes', '--database-url', '@DB@', '--json'] }], - }, 'commands/migrate/account-issuer.ts': { run: invoke(MigrateAccountIssuer), noWrite: [{ label: 'migrate account-issuer', argv: ['--database-url', '@DB@', '--json'] }], @@ -309,7 +303,7 @@ interface RunResult { * process-global and `emitJson` sets it, so it is saved and restored. The * stdout spy is installed before the boot, because a `--json` boot reserves * stdout and keeps whatever `process.stdout.write` is at that moment. - * `os meta resync` ends in `process.exit()`, so that is trapped too. + * A command that ends in `process.exit()` is trapped too. */ async function runJson(command: Invoke, argv: string[]): Promise { const savedExit = process.exitCode; diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index c59ed50e908..a3b8cb6ef2b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -137,7 +137,7 @@ export interface SchemaStack { dbSource: DatabaseSource; managedTableCount: number; /** The booted kernel — `getService('objectql')` etc. for one-shot commands - * beyond schema migration (e.g. `os meta resync`, #2705). */ + * beyond schema migration. */ kernel: any; /** * Create-table / add-column work the boot sync was held back from running @@ -579,7 +579,7 @@ export async function bootSchemaStack( * a `--json` flag, and each of them re-introduced the same defect * independently: `os migrate plan` / `apply` / `resume` / `recorded-by` / * `summary-nulls` / `value-shapes` / `files-to-references`, `os migrate - * meta --stored` and `os meta resync` all emitted ~60 INFO lines around + * meta --stored` and the since-retired `os meta resync` all emitted ~60 INFO lines around * their payload. Booting the stack is what makes a command a member of * this family, so this is the one place a new member cannot avoid — and * with no default, a new member has to *decide* rather than inherit the @@ -641,7 +641,7 @@ export async function bootSchemaStack( * about. * * A WRITE mode that needs the tables to exist before it writes (`--apply` - * of the data commands, `os meta resync --yes`) leaves this off. + * of the data commands) leaves this off. */ deferSchemaDdl?: boolean; /** @@ -789,7 +789,7 @@ export async function bootSchemaStack( // • the write modes — `--apply` of `os migrate meta --stored` / // `value-shapes` / `recorded-by` / `summary-nulls` / // `files-to-references` / `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes` — boot + // --run`, `os secret orphans --delete` — boot // NOT deferred, so a `defer`-keyed policy would have left all of them // repairing rows. They write, but only the change the operator // confirmed; `os migrate apply` likewise. A repair riding along is a @@ -1063,8 +1063,8 @@ export async function bootSchemaStack( * package is `import type`, which erases. A *static* value import for it was * not a local cost, because this file is not loaded only when someone migrates: * oclif's `findCommand` `import()`s every command module on **every** CLI - * invocation, and nine commands reach this file (`meta:resync`, `migrate`, and - * seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not + * invocation, and nine commands reached this file (then `meta:resync`, `migrate`, + * and seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not * merely break those nine — running *any* command, `os dev` included, printed * one `MODULE_NOT_FOUND` block per command in front of the output you asked for * (and `os dev` forks a child, so you saw each one twice), while the nine diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 698034ba9bc..0bb95fb871e 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -81,7 +81,6 @@ const COMMANDS_DIR = resolve(HERE, '../src/commands'); * --stored` rather than `migrate meta`. */ const FAMILY: Record = { - 'meta resync': [], // Its bare form boots: it takes the `os migrate plan` shape (`deferSchemaDdl` // + `readOnlyProbe`), so a fixture with no database file is opened as an // empty in-memory one rather than being brought into existence. No table diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 01edeb164c7..64886ac39d7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7067,7 +7067,7 @@ export class SqlDriver implements IDataDriver { * Put a file-backed SQLite database in WAL mode (#3941). * * ObjectStack's normal shape is **several processes on one file**: a dev - * server, `os migrate`, `os meta resync`, a test run. SQLite's built-in + * server, `os migrate`, a test run. SQLite's built-in * default — `journal_mode = delete`, a rollback journal — is the worst mode * for that, in two measured ways: * diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts index d57f5e44193..6e35b982e44 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts @@ -1,22 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#22169] `bootstrapPlatformAdmin`'s existence read: a read that did not + * [#22169] `bootstrapPlatformAdmin`'s one catalog read: a read that did not * answer is not an absent row. * - * The seed loop reads each default permission set BY NAME and inserts when it - * finds none. Its `tryFind` answers `[]` for a refused read as well as for an - * empty table, and the loop inserted on both — so on a table without the - * per-organization unique index a refused read minted a second row of a set - * that already existed, and a table holding such duplicates can never build - * that index again. Measured on a real engine with that index dropped: two - * declared sets, two INSERTs, under a refused read. + * [ADR-0131 D3/D4] The bootstrap writes no `sys_permission_set` row. It still + * READS the legacy `admin_full_access` row an older release seeded, because + * that row's id is how the holder scan reaches a grant written before the + * name column existed (leg C, restrict-only). A refused read cannot say no + * such grant exists, so the promotion is withheld — loudly — rather than risk + * minting a second administrator. * - * The double below has no `update` or `delete` member — the seed pass without - * `resync` calls neither — so it is not an engine double those dispatch - * contracts govern. Its `find` answers from its rows, bounded by the caller's - * `limit`; the refused read is a spy laid over it, so the two inputs the pass - * must tell apart — an answer, and a throw — come from one table. + * The double has no `update` or `delete` member — the bootstrap calls neither — + * so it is not an engine double those dispatch contracts govern. Its `find` + * answers from its rows, bounded by the caller's `limit`; the refused read is a + * spy laid over it. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -26,7 +24,7 @@ afterEach(() => { vi.restoreAllMocks(); }); function makeQl(seedRows: any[] = []) { const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: any[] = []; + const inserted: Array<{ object: string; data: any }> = []; return { rows, inserted, @@ -40,9 +38,7 @@ function makeQl(seedRows: any[] = []) { return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; }, async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - inserted.push({ ...data }); - rows.push({ ...data }); + inserted.push({ object, data: { ...data } }); return { id: data.id }; }, }; @@ -64,43 +60,30 @@ function capture() { return { warns, logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; } -describe('#22169 — bootstrapPlatformAdmin declines the insert when its existence read was refused', () => { - it('a refused read inserts NOTHING, and says so once for the whole pass', async () => { +describe('#22169 — bootstrapPlatformAdmin withholds the promotion when its legacy read was refused', () => { + it('a refused read writes NOTHING, reports the read that failed, and says so once', async () => { const ql = makeQl([{ id: 'ps_existing', name: 'admin_full_access' }]); refuseSetReads(ql); const { warns, logger } = capture(); - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default'), set('viewer_readonly')], { logger }); + const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted, '⛔ no row minted on a read that did not answer').toEqual([]); - expect(r.seeded).toBe(0); - const lines = warns.filter((w) => w.msg.includes('could not be read')); - expect(lines, 'one line per pass, never one per set').toHaveLength(1); - expect(lines[0]!.meta).toMatchObject({ - unreadable: 3, total: 3, names: ['admin_full_access', 'member_default', 'viewer_readonly'], - }); - // The promotion half reports the read that failed, not a set that is "missing". + expect(ql.inserted, '⛔ nothing written on a read that did not answer').toEqual([]); expect(r.reason).toBe('admin_permission_set_unreadable'); + expect(r.adminPromoted).toBe(false); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toHaveLength(1); }); - it('control: an EMPTY answer is still "absent" — a fresh install seeds every default set', async () => { + it('control: an EMPTY answer is "no legacy row" — the pass goes on, and writes no catalog row', async () => { const ql = makeQl(); const { warns, logger } = capture(); const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted.map((row) => row.name)).toEqual(['admin_full_access', 'member_default']); - expect(r.seeded).toBe(2); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); - - it('control: an existing row is found and left alone — no insert, no unreadable line', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'member_default' }]); - const { warns, logger } = capture(); - - await bootstrapPlatformAdmin(ql, [set('member_default')], { logger }); - - expect(ql.inserted).toEqual([]); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); + expect(ql.inserted.filter((w) => w.object === 'sys_permission_set')).toEqual([]); + // No users in this double, so the pass ends at "nobody to promote yet" — + // past the legacy read, which is the point. + expect(r.reason).toBe('no_users'); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toEqual([]); }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts deleted file mode 100644 index 92957e006b3..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the - * platform default permission sets, and what `os meta resync` then does with it. - * - * ## Why this file exists at all - * - * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — - * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every - * one of them hand-builds the pre-image. That is correct unit coverage of the - * BRANCH, and it is deliberately left untouched here. What none of them can - * answer is which pre-image the seeder itself produces, because a fake `ql` - * stores exactly the columns it is handed and a declared field `defaultValue` - * never runs. So the one row shape the platform actually creates was the one - * row shape nothing exercised, and the drift was invisible in both directions. - * - * This file closes that by seeding through the REAL shipped - * `defaultPermissionSets` against a real `ObjectQL` engine over a real - * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and - * `os meta resync` use, which hand the bare engine straight to the seeder. - * - * ## The ruling these assertions now pin (2026-08-15, Option A) - * - * The original version of this file recorded a MEASUREMENT and explicitly - * refused to endorse it: a seeded row stored `'admin'` (the declaration's - * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` - * and skipped every platform default set — the inverse of what #2705 built the - * flag for. It left the product question open: - * - * > are the platform default permission sets meant to be platform-owned - * > (and therefore resyncable), or env-authored (and therefore deliberately - * > left alone)? - * - * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: - * the platform default sets are platform-owned and resyncable, and the seeder - * stamps `managed_by: 'platform'` on its insert. This file no longer records an - * undecided state — it pins the ruling, on BOTH sides of the line the ruling - * drew: - * - * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real - * resync reconciles every default set. - * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries - * `'admin'` and is still SKIPPED, forever and on purpose. The ruling - * forbids a migration: a stored `'admin'` cannot be told apart from a - * genuine Setup takeover, so restamping could silently overwrite a real - * admin's edits on the next resync. Report, don't rewrite. - * - * Section 2 is the half that is easy to lose. Deleting it would leave the - * upgrade path — the one every existing deployment is actually on — with no - * coverage at all, and a later "cleanup" that restamps legacy rows would then - * go green. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -// `sys_user` is what the promotion half enumerates. Registered as the REAL -// declaration so its verdict ("no human users yet") is a genuine reading of an -// empty table rather than a table-missing error `tryFind` swallowed into `[]` — -// the two are indistinguishable from the outside and only one of them is the -// first-boot ordering this file means to reproduce. -import { SysUser } from '@objectstack/platform-objects/identity'; -// The REAL shipped seeder and the REAL shipped declarations. A local -// re-implementation of either would make this a test of the copy, which is -// precisely the gap the file exists to close. -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYSTEM_CTX = { isSystem: true }; - -const engines: ObjectQL[] = []; - -afterEach(async () => { - while (engines.length) { - try { - await engines.pop()?.destroy(); - } catch { - /* noop */ - } - } -}); - -/** - * A fresh engine on its own `:memory:` database, carrying the permission-set - * declaration passed in. `declaration` exists for section 3's counterfactual; - * every case in sections 1 and 2 boots the shipped declaration unmodified. - */ -async function boot(declaration: any = SysPermissionSet): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.security-objects', - name: 'Security Objects', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [declaration, SysUserPermissionSet, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The row as it is physically STORED, read at the driver past every engine-side - * projection. The resync branch reads the engine's view, so both are asserted - * where it matters — a default applied only on read and a value written on - * insert are different facts about the database, and only one of them survives - * a restart. - */ -async function storedRow(engine: ObjectQL, name: string): Promise { - const driver: any = (engine as any).getDriver('sys_permission_set'); - const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); - return rows[0]; -} - -/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ -async function rowViaEngine(engine: ObjectQL, name: string): Promise { - const rows: any[] = await (engine as any).find( - 'sys_permission_set', - { where: { name }, limit: 1 }, - { context: SYSTEM_CTX }, - ); - return rows[0]; -} - -/** - * A row exactly as an install before commit 712e185db holds it — written the way the old - * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes - * from the declaration's `defaultValue: 'admin'` by the very mechanism that - * produced it on every install created before the ruling. - * - * Reproducing the legacy pre-image through its original mechanism rather than - * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real - * declaration, and each case asserts the resulting value before relying on it, - * so a moved default shows up as a failure here instead of quietly turning the - * legacy pin into a test of something else. - */ -async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { - await (engine as any).insert( - 'sys_permission_set', - { - id: `ps_legacy_${ps.name}`, - name: ps.name, - label: ps.label ?? ps.name, - // A deliberately STALE payload: the resync would rewrite these if it ever - // touched the row, so section 2 can prove "left untouched" by content and - // not merely by a counter. - object_permissions: '{}', - field_permissions: '{}', - system_permissions: '[]', - row_level_security: '[]', - tab_permissions: '{}', - active: true, - }, - { context: SYSTEM_CTX }, - ); -} - -function collectingLogger() { - const info: string[] = []; - const warn: string[] = []; - return { - info, - warn, - logger: { - info: (m: string) => info.push(m), - warn: (m: string) => warn.push(m), - }, - }; -} - -describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { - // ─────────────────────────────────────────────────────────────────────────── - // 1. FORWARD — a fresh install after the ruling - // ─────────────────────────────────────────────────────────────────────────── - - it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { - // Every count below is relative to this set. If it were empty, "resync - // reconciled all of them" would be trivially true of nothing — the shape in - // which this whole file could pass while measuring air. - expect(defaultPermissionSets.length).toBeGreaterThan(0); - - const engine = await boot(); - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect(out.seeded).toBe(defaultPermissionSets.length); - }); - - it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { - // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored - // `'admin'` — the declaration's `defaultValue` — because the insert omitted - // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts`, which always has. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - // Physically stored, not merely projected on read: the value survives a - // restart, so every later boot reads it too. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - // …and the same value is what the seeder's own read sees, which is the one - // the resync condition branches on. - expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - it('so `os meta resync` now reconciles EVERY platform default set', async () => { - // The behaviour #2705 built the flag for, finally reachable: resynced == the - // whole shipped set, resyncSkipped 0. Asserted against the live length so - // the pin stays true if a default set is added or removed. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); - - it('a resync of a fresh install warns about nothing', async () => { - // The counterpart to the old measurement, which emitted one bogus - // "intentional override" warn per shipped set. Nothing is skipped now, so - // nothing is warned about. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([]); - }); - - it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { - // Counts alone cannot show the reconcile actually landed. Blank the payload - // on a seeded row, resync, and read the declaration back out of storage. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const driver: any = (engine as any).getDriver('sys_permission_set'); - await driver.knex('sys_permission_set') - .where({ name: 'admin_full_access' }) - .update({ system_permissions: '[]' }); - expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); - - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; - expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) - .toEqual(shipped.systemPermissions ?? []); - // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries - // no `managed_by`, which is what keeps a resync from restamping any row. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 2. LEGACY — a pre-ruling install, which keeps the skip forever - // ─────────────────────────────────────────────────────────────────────────── - - it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { - // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A - // stored `'admin'` is indistinguishable between "the old seeder's field - // default" and "an administrator took this set over in Setup", so it must - // never be reconciled on the strength of a guess. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - - // The pre-image really is what a pre-ruling install holds — asserted, not - // assumed, so this case cannot pass by testing some other row shape. - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - // An upgraded install: the seeder runs, finds the legacy row and leaves it, - // and inserts the remaining sets freshly stamped `'platform'`. - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - // The mixed shape an upgrade produces: everything the platform stamped is - // reconciled, the one legacy row is not. - expect(out.resyncSkipped).toBe(1); - expect(out.resynced).toBe(defaultPermissionSets.length - 1); - - // "Left untouched" proven by CONTENT, not just by a counter: the stale - // payload seeded above is still stale, and provenance was not restamped. - const row = await storedRow(engine, legacy.name); - expect(row.system_permissions).toBe('[]'); - expect(row.object_permissions).toBe('{}'); - expect(row.managed_by).toBe('admin'); - }); - - it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { - // The warn used to end "(intentional override)", which is false for exactly - // these rows: on a pre-ruling install the only writer may have been the - // seeder itself one call earlier. The reworded line states the provenance - // and the action and asserts nothing about anybody's intent. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([ - `[security] resync left ${legacy.name} untouched — row is admin-owned`, - ]); - // Pinned as a substring too, so a future reword cannot quietly reintroduce - // the intent claim in some other sentence shape. - expect(warn.join('\n')).not.toContain('intentional override'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 3. ATTRIBUTION — which link actually supplies the value - // ─────────────────────────────────────────────────────────────────────────── - - /** - * A counterfactual, and note it runs the OPPOSITE way round from the one this - * file carried before the ruling. Back then the seeder wrote no `managed_by`, - * so respelling the declared default flipped the stored value — which is what - * proved the default was the source. Now the seeder stamps explicitly, so the - * declared default must no longer be able to move the value at all. - * - * Predicted direction, written down before running: boot a CLONE of the - * declaration whose `managed_by` default is respelled to `'package'`, seed - * with the same unmodified seeder, and the row must STILL store `'platform'`. - * - * That one reading establishes what section 1 cannot on its own: the value - * comes from the seeder's stamp and not from a declaration that merely - * happens to agree with it. Without this case, respelling - * `defaultValue: 'admin'` to `'platform'` in the object file would make every - * assertion in section 1 pass with the stamp deleted. - */ - it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { - const respelled = { - ...SysPermissionSet, - fields: { - ...(SysPermissionSet as any).fields, - managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, - }, - }; - // Guard: the clone is a copy, and the shipped declaration is untouched by - // building it. Without this the case could be measuring a mutation it made. - // The shipped default stays `'admin'` deliberately — it is the right default - // for a set an admin creates in Setup, and the ruling changed the SEEDER, - // not the declaration. - expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); - - const engine = await boot(respelled); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - - // And the consequence still holds under a default that would otherwise have - // caused a skip: resync reconciles everything. - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts deleted file mode 100644 index 9701a38b887..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapPlatformAdmin — permission-set materialization, focused on the - * insert-once vs `resync` (#2705) split. - * - * The default boot path is insert-once: an existing default permission-set row - * is env-authored config and is never clobbered on restart (so admin Setup - * edits survive). That protection is CORRECT for prod but makes a dev source - * edit silently stale until a `--fresh` wipe. `os meta resync` passes - * `{ resync: true }` to reconcile platform-owned rows to the shipped dist - * without touching business data — while still refusing to overwrite a row an - * admin or a package has explicitly taken over. - */ - -import { describe, it, expect } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { applyManagedWriteDenies } from './managed-object-write-denies.js'; - -/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- - * promotion tables return empty, so promotion short-circuits and we assert the - * seed/resync outcome (carried on every return) directly. */ -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -/** Shipped declaration: member_default now grants `setup.access`. */ -const memberDefault = (over: Record = {}) => - ({ - name: 'member_default', - label: 'Member', - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access'], - ...over, - }) as any; - -const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); - -describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { - it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); - expect(r.resynced).toBe(0); - // Same row, and the shipped `setup.access` did NOT land — this is the - // #2705 boot behavior (protects admin edits; stale in the dev loop). - expect(row(ql)!.id).toBe('ps_old'); - expect(row(ql)!.system_permissions).toBe('[]'); - }); - - it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(1); - expect(r.resyncSkipped).toBe(0); - // Updated in place (no new insert), and the declaration is now live. - expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); - expect(row(ql)!.id).toBe('ps_old'); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { - const ql = makeQl([ - { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); - }); - - it('resync: leaves a package-owned row untouched', async () => { - const ql = makeQl([ - { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - }); - - it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { - const ql = makeQl([]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(row(ql)).toBeTruthy(); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - }); -}); - -// #3325 — the transform runs upstream in runBootstrap; here we confirm that once -// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin -// serializes the injected denies into the seeded object_permissions JSON. -describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { - const schemas = [ - { name: 'sys_sso_provider', managedBy: 'better-auth' }, - { name: 'crm_lead', managedBy: 'platform' }, - ]; - // member_default is a target set; give it the wildcard shape the real one has. - const enriched = () => { - const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; - applyManagedWriteDenies([set], schemas); - return set; - }; - - it('fresh DB: the injected deny serializes into object_permissions', async () => { - const ql = makeQl([]); - await bootstrapPlatformAdmin(ql, [enriched()]); - const perms = JSON.parse(row(ql)!.object_permissions); - expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); - expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied - }); - - it('resync: reconciles a stale platform-owned row that predates the deny', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); - expect(r.resynced).toBe(1); - expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index e67616aba1e..edf2a164d98 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -3,12 +3,8 @@ /** * bootstrapPlatformAdmin — first-boot platform admin promotion. * - * Two responsibilities, both idempotent and run on `kernel:ready`: - * - * 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets` - * entry (admin_full_access / member_default / viewer_readonly). - * - * 2. **Answer the platform-admin question, POSTURE-KEYED** (#11184 ruling + * One responsibility, idempotent and run on `kernel:ready`: **answer the + * platform-admin question, POSTURE-KEYED** (#11184 ruling * 2026-08-23; re-anchored by #11663, maintainer acceptance 2026-08-25, * leg L4 = #11974): * - `single`: promote the first registered human user by inserting a @@ -48,31 +44,12 @@ * `ensureDefaultOrganization`). Install that plugin to get * multi-tenant bootstrap. * - * ## Provenance of the seeded permission-set rows (commit 712e185db, ruled 2026-08-15) - * - * 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 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 - * the declared `defaultValue: 'admin'` in `objects/sys-permission-set.object.ts` - * — measured on a real engine (commit db923a3a8: a seeded row stored `'admin'`, and a real - * resync returned `resynced 0 / resyncSkipped 8`, skipping every shipped - * default set). - * - * For those legacy rows the resync SKIP stands, permanently and by decision: - * a stored `'admin'` is **indistinguishable** between "the old seeder's field - * default" and "an administrator took this set over in Setup". So there is - * deliberately **no migration and no restamp** — rewriting them to `'platform'` - * would make genuine admin customizations reconcilable and could silently - * overwrite them on the next `os meta resync`. Report, don't rewrite. A legacy - * install that wants the platform defaults reconciled has to re-own the rows - * deliberately (or re-seed with `--fresh`); that is an operator's choice to - * make, not one a boot should make on their behalf. + * [ADR-0131 D3/D4] It writes no `sys_permission_set` row. The platform's + * default sets (admin_full_access / member_default / viewer_readonly …) are + * definitions this plugin ships on its manifest and the security catalog + * serves; the promotion grant names its set. The materialization that used to + * seed those rows insert-once — and the `os meta resync` reconcile of them — + * retired with the rest of the catalog seeders. */ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; @@ -85,12 +62,6 @@ import { import { normalizePlatformAdminEmail, resolvePlatformAdminEmails } from '@objectstack/core'; import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; import { resolvePlatformAdminStanding, type PlatformAdminStandingEntry, @@ -105,6 +76,7 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; +import { reportThroughSink } from './seed-refusal-sink.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -219,7 +191,7 @@ async function recordPlatformAdminStandingChange( // for that context to be forgotten — it is also a second row on the // tenant-audit write-call-site census for one write. let insertRefusal: unknown; - await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => { + await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, (e) => { insertRefusal = e; }); if (insertRefusal !== undefined) { @@ -251,24 +223,6 @@ interface BootstrapOptions { */ error?: (message: string, meta?: any) => void; }; - /** - * [#2705] Force re-materialization of the default permission-set rows from - * the compiled declaration. - * - * Default (`false`) keeps the insert-once shape: an existing row is left - * untouched so an admin's Setup customizations survive every restart. This is - * correct for prod boot. - * - * `os meta resync` sets it to `true` to reconcile the DB rows to the shipped - * `dist` after a source edit — the dev loop that insert-once otherwise makes - * silently stale (a changed default set is served with its OLD value until a - * `--fresh` wipe). Only platform-owned rows (`managed_by` absent or - * `'platform'`) are overwritten. Rows carrying any other provenance are left - * alone: `'user'` / `'admin'` (taken over in Setup — or, on an install older - * than commit 712e185db, seeded before the platform stamped its own rows) and - * `'package'` (owned by package metadata). - */ - resync?: boolean; /** * The seed pipeline's tally at the moment this bootstrap runs, read by the * caller through the published `seed-settlement` contract. @@ -277,7 +231,7 @@ interface BootstrapOptions { * the claim pass is the only step here whose answer depends on whether the * platform's own seeder has finished writing, and a pass that cannot say so * reports "claimed 0 of 0" for both "nothing to claim" and "nothing had - * landed yet". Absent for callers with no kernel context (`os meta resync`), + * landed yet". Absent for callers with no kernel context, * which the claim reports as `unattested` rather than guessing. */ seedSettlement?: SeedSettlementSnapshot | undefined; @@ -391,40 +345,22 @@ async function tryFind( } } -// ⛔ The `catch` RECORDS the refusal before it answers, when the caller passed -// a log to record into. Answering `null`/`false` alone is what made a refused -// write indistinguishable from "nothing to do": `seeded` never grows, the pass -// returns normally, and the boot reports a successful seed of zero rows. Still -// no rethrow — this pass reports, it does not decide whether the deployment -// boots. See `reportSeedWriteRefusals` in `write-refusals.ts`. +// Never rethrows — this pass reports, it does not decide whether the +// deployment boots. A caller that must tell a refused write from "nothing to +// do" passes `onRefusal` and is told. async function tryInsert( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, - // Symmetric with {@link tryFind}'s observer, and for the same reason: a - // caller outside the SEED pass needs the refusal itself, not a `null` that - // the seed reporter will later summarize on a channel that is not its own. + ql: any, object: string, data: any, + // Symmetric with {@link tryFind}'s observer, and for the same reason. onRefusal?: (error: unknown) => void, ): Promise { try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch (e) { - refusals?.record(object, e); onRefusal?.(e); return null; } } -async function tryUpdate( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: SYSTEM_CTX }); - return true; - } catch (e) { - refusals?.record(object, e); - return false; - } -} - function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); @@ -507,40 +443,6 @@ export function shouldReplayBootstrapFor(opCtx: { return resolvePlatformAdminEmails().emails.length > 0; } -/** - * The platform-owned definition facets of a default permission set — the - * fields the runtime resolver hydrates back into ExecutionContext - * (`resolve-authz-context.ts` → systemPermissions / tabPermissions / object & - * field masks). Single source for both the first-boot insert and the `#2705` - * resync update so the two paths can never drift. Identity/provenance columns - * (`id`, `name`, `active`, `managed_by`, `package_id`) are deliberately NOT - * here — resync reconciles the declaration, never the ownership. - * - * [commit 712e185db] `managed_by` must stay out of this helper even though the seed insert - * now stamps it. Both paths share these fields, so adding it here would make - * every resync RESTAMP the row it reconciles -- silently converting a legacy - * `admin`-owned row (which may be a real Setup takeover) into a platform-owned - * one and clobbering it on that same pass. The insert stamps provenance at its - * own call site precisely so the resync update cannot. - * - * `description` / `adminScope` are read defensively: neither is on the typed - * PermissionSet shape (name/label/objects/fields/...), but both persist when a - * runtime declaration provides them without tripping the dts typecheck. - */ -function platformOwnedFields(ps: PermissionSet): Record { - return { - label: ps.label ?? ps.name, - description: (ps as any).description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row. - admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, - }; -} - /** * The permission set whose unscoped grant makes a user the platform admin under * `single`. Named once so the bootstrap's guard and @@ -753,17 +655,15 @@ export async function findExistingPlatformAdmin( } /** - * Persist seed permission sets and answer the posture-keyed platform-admin - * question: promote the first registered human user under `single`, report - * config-derived standing (and write nothing) under walled postures. Safe to - * call multiple times. + * Answer the posture-keyed platform-admin question: promote the first + * registered human user under `single`, report config-derived standing (and + * write nothing) under walled postures. Safe to call multiple times. */ export async function bootstrapPlatformAdmin( ql: any, bootstrapPermissionSets: PermissionSet[], options: BootstrapOptions = {}, ): Promise<{ - seeded: number; adminPromoted: boolean; reason?: string; /** Count of seeded rows re-owned to the freshly-promoted admin. */ @@ -787,10 +687,6 @@ export async function bootstrapPlatformAdmin( * to get right). */ adminUserId?: string; - /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ - resynced?: number; - /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ - resyncSkipped?: number; /** * [commit 9b9581b11] WHY this target was chosen, when one was. `declared-owner` means * `OS_PLATFORM_OWNER_EMAIL` named them; `oldest-authenticable` means nobody @@ -810,128 +706,22 @@ export async function bootstrapPlatformAdmin( }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, adminPromoted: false, reason: 'objectql_unavailable' }; + return { adminPromoted: false, reason: 'objectql_unavailable' }; } - // 1. Seed permission set rows. - const seeded: Record = {}; - let resynced = 0; - let resyncSkipped = 0; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY default permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#22169] Names whose existence read was REFUSED, so nothing was inserted - // for them — reported once below, after the loop. - const unreadable: string[] = []; - for (const ps of bootstrapPermissionSets) { - if (!ps.name) continue; - // ⛔ "Refused" is not "absent". `tryFind` answers `[]` for both, and this - // loop used to INSERT on both: on a table without the per-organization - // unique index (remote tables built before its retrofit) a refused read - // minted a second row of a set that already existed, and a table holding - // such duplicates can never build that index again. The observer tells - // the two apart; a read that could not answer declines the insert. - let readRefused = false; - const existing = await tryFind( - ql, 'sys_permission_set', { name: ps.name }, 1, undefined, undefined, - () => { readRefused = true; }, - ); - if (readRefused) { - unreadable.push(ps.name); - continue; - } - if (existing.length > 0 && existing[0].id) { - const row = existing[0]; - seeded[ps.name] = row.id; - // Insert-once by default: an existing row is never clobbered on restart, - // which is what protects an admin's Setup edits. Under `resync` - // (`os meta resync`, #2705) reconcile the row to the shipped dist so a dev - // source edit takes effect without `--fresh` -- but only for rows the - // platform still owns. - if (options.resync) { - if (!row.managed_by || row.managed_by === 'platform') { - if (await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...platformOwnedFields(ps) }, refusals)) { - resynced += 1; - } - } else { - resyncSkipped += 1; - // [commit 712e185db] Neutral by ruling: state the provenance and the action, and - // claim NOTHING about intent. This used to say "(intentional - // override)", which is a lie for every row on a pre-ruling install -- - // there the only writer may have been this very seeder one call - // earlier, inheriting `defaultValue: 'admin'` rather than any admin - // deciding anything. The stored value cannot tell the two apart, so - // the log must not pretend it can. - logger?.warn?.( - `[security] resync left ${ps.name} untouched — row is ${row.managed_by}-owned`, - { name: ps.name, managedBy: row.managed_by }, - ); - } - } - continue; - } - const id = genId('ps'); - const created = await tryInsert(ql, 'sys_permission_set', { - id, - name: ps.name, - ...platformOwnedFields(ps), - active: true, - // [commit 712e185db] Stamp provenance EXPLICITLY rather than letting it fall to the - // declaration's `defaultValue: 'admin'`. Without this the platform's own - // 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. 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; - else if (created) seeded[ps.name] = id; - } - - // Reported HERE rather than at function end: every `return` below this point - // is an early exit of the PROMOTION half, and the catalog seed above is - // finished either way. Placing it at the end would make the diagnosis - // conditional on how promotion happened to resolve. - reportSeedWriteRefusals(logger, refusals); - if (unreadable.length > 0) { - // [#22169] ONCE per pass with the names, never a line per set: a refused - // read usually means the whole table is unreadable, and a per-name line - // would bury its own remedy. `warn`, through the sink derivation the - // sibling seeders' unreadable lines use, so it is heard with no logger - // injected: nothing was written, so nothing claims to have landed. - reportThroughSink( - logger, - '[security] platform default permission sets left untouched — their sys_permission_set rows could not ' + - 'be read, so none was inserted: a read that did not answer is not an absent row, and inserting on it ' + - 'duplicates a set that may already exist. A set that is genuinely absent is seeded by the next boot ' + - 'whose read answers.', - { unreadable: unreadable.length, total: bootstrapPermissionSets.length, names: unreadable }, - ); - } - - const seededCount = Object.keys(seeded).length; - // [#11532] Under a walled posture these rows are organization-less BY RULING - // (#10103, 2026-08-20) and unreadable through the wall, and the catalog pass - // that runs next reports them once per organization. Saying so HERE is what - // stops the operator's first sight of them being a warning that calls the - // platform's own output legacy state: the fresh walled rig logged - // `seeded: 8` with nothing to indicate the rows carried no organization at - // all. Not a behaviour change — the seeding above is byte-identical. - if (seededCount > 0 && postureEnforcesWall(resolveTenancyPosture())) { - logger?.info?.( - '[security] platform default permission sets seeded WITHOUT an organization (the platform ' + - 'bucket) — ruled 2026-08-20 and unchanged: the platform-admin grant points at the ' + - 'admin_full_access row by id. Under a walled posture they are unreadable through the ' + - 'tenant wall; each organization gets its own copies from the per-organization catalog ' + - 'pass, so no principal is missing a set.', - { seeded: seededCount, names: Object.keys(seeded).sort() }, - ); - } - // Attached to every return below so `os meta resync` can report the reconcile - // outcome even when admin promotion short-circuits (the common dev case: a DB - // that already has an admin returns `already_have_admin`). - const resyncCounts = { resynced, resyncSkipped }; + // 1. [ADR-0131 D3/D4] No catalog row is written: the platform's default + // sets are definitions this plugin ships on its manifest, served from + // the registry, and a grant names its set. The one read left is the + // LEGACY `admin_full_access` row an older release seeded, whose id is how + // leg C of {@link findPlatformAdminGrantHolder} reaches a grant written + // before the name column existed. ⛔ "Refused" is not "absent": a read + // that did not answer cannot say no such grant exists, so the promotion + // is withheld below rather than risk minting a second administrator. + let legacyAdminRowUnreadable = false; + const legacyAdminRows = await tryFind( + ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1, undefined, undefined, + () => { legacyAdminRowUnreadable = true; }, + ); // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): @@ -954,18 +744,28 @@ export async function bootstrapPlatformAdmin( // [ADR-0131 D4] The grant names `admin_full_access`; the set is the catalog // definition this plugin ships, so no `sys_permission_set` row is needed to - // grant it. The seeded row's id, when there is one, feeds only the legacy + // grant it. The legacy row's id, when there is one, feeds only the // restrict-only leg of the holder scan ({@link findPlatformAdminGrantHolder}). if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; + return { adminPromoted: false, reason: 'admin_permission_set_missing' }; } - if (unreadable.includes(PLATFORM_ADMIN_PERMISSION_SET_NAME)) { + if (legacyAdminRowUnreadable) { // [#22169] The set's row table did not answer, so the legacy leg cannot ask // whether an unnamed grant already makes someone the administrator; no - // promotion is made on a question this pass could not ask. - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_unreadable', ...resyncCounts }; + // promotion is made on a question this pass could not ask. Said through + // the sink derivation, so it is heard with no logger injected. + reportThroughSink( + logger, + '[security] platform-admin promotion WITHHELD: the legacy admin_full_access sys_permission_set row ' + + 'could not be read, so this pass cannot ask whether a grant written before the name column existed ' + + 'already makes someone the platform administrator. Nothing was written; the next pass whose read ' + + 'answers decides.', + { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, + ); + return { adminPromoted: false, reason: 'admin_permission_set_unreadable' }; } - const adminPsId: string | undefined = seeded[PLATFORM_ADMIN_PERMISSION_SET_NAME]; + const adminPsId: string | undefined = + typeof legacyAdminRows[0]?.id === 'string' && legacyAdminRows[0].id !== '' ? legacyAdminRows[0].id : undefined; // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // @@ -1010,7 +810,6 @@ export async function bootstrapPlatformAdmin( // config-derived standing, or to refuse fail-closed when nobody is declared. if (!walled && unscopedHolder) { return { - seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', // The promotion is a no-op forever; the CLAIM is not. The seed-settle @@ -1018,7 +817,6 @@ export async function bootstrapPlatformAdmin( // has run at all, asks {@link findExistingPlatformAdmin}, which answers // with this same holder (see `adminUserId` above). ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), - ...resyncCounts, ...grantScanCounts, }; } @@ -1040,10 +838,8 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { - seeded: seededCount, adminPromoted: false, reason: 'admin_grant_unnamed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1071,7 +867,7 @@ export async function bootstrapPlatformAdmin( // process). The startup half (walled + undeclared ⇒ REFUSE BOOT, naming // the variable) lives in plugin-auth's `init()`; this is the // defense-in-depth line for paths that reach the bootstrap without that - // guard (`os meta resync`, embeddings without plugin-auth). + // guard (embeddings without plugin-auth). // // [#11663 L5] ⛔ This line is no longer skipped when a legacy holder exists. // It used to be, and the reason it used to be has expired: L4's skip was @@ -1106,10 +902,8 @@ export async function bootstrapPlatformAdmin( if (logger?.error) logger.error(message); else logger?.warn?.(message); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_owner_email_undeclared', - ...resyncCounts, ...grantScanCounts, }; } @@ -1142,10 +936,8 @@ export async function bootstrapPlatformAdmin( // CHANGE of standing, plus the first-boot baseline. await recordPlatformAdminStandingChange(ql, standing, logger); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_config_derived', - ...resyncCounts, ...grantScanCounts, }; } @@ -1251,10 +1043,8 @@ export async function bootstrapPlatformAdmin( if (!inserted) { logger?.warn?.(`[security] failed to grant admin_full_access to first user ${chosen.email ?? chosen.id}`); return { - seeded: seededCount, adminPromoted: false, reason: 'insert_failed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1286,12 +1076,10 @@ export async function bootstrapPlatformAdmin( } return { - seeded: seededCount, adminPromoted: true, ownershipClaimed, adminUserId: String(chosen.id), basis: audit.basis, - ...resyncCounts, ...grantScanCounts, }; }; @@ -1454,7 +1242,6 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(message); else logger?.info?.(message); return { - seeded: seededCount, adminPromoted: false, // [commit 9b9581b11, batch #100] The ruling allows either a distinct code or a // fold into `declared_owner_not_authenticable` with verification named @@ -1463,7 +1250,6 @@ export async function bootstrapPlatformAdmin( // reading a structured sink can tell "register a login" apart from // "click the link in your mailbox". reason: unverifiedOnly ? 'declared_owner_not_verified' : 'declared_owner_not_authenticable', - ...resyncCounts, ...grantScanCounts, }; } @@ -1487,7 +1273,7 @@ export async function bootstrapPlatformAdmin( } if (scannedHumans === 0) { logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts, ...grantScanCounts }; + return { adminPromoted: false, reason: 'no_users', ...grantScanCounts }; } if (!target) { // [#14348] Humans exist, but not one of them can sign in. Measured on a @@ -1522,10 +1308,8 @@ export async function bootstrapPlatformAdmin( else logger?.info?.(truncation); } return { - seeded: seededCount, adminPromoted: false, reason: 'no_authenticable_user', - ...resyncCounts, ...grantScanCounts, }; } diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index c4898cbfcc6..ae4bfca26a3 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -561,7 +561,7 @@ export async function claimSeedOwnership( // nor any later object. The rows stay unowned and the next run claims // them, because the predicate is still true of them. That run is the // next `app:seeded` (this boot or a later one) or the next promotion — - // ⚠️ NOT `os meta resync` and NOT a bootstrap replay: on an install that + // ⚠️ NOT a bootstrap replay: on an install that // already has an admin both short-circuit on `already_have_admin` and // never reach this function. logger?.warn?.( diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 9e91bba87ed..463ca038640 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -1677,7 +1677,7 @@ export async function reconcilePermissionSetProjection( '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall, `meta resync`) recreates none ' + + 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ec6d82723f3..fc8fb2beee0 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -812,7 +812,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // // Keyed on an EXPLICIT `dev: true`, never on `factoryDev`'s NODE_ENV // default: `bootSchemaStack` passes no `dev`, and a non-deferred one-shot - // boot (`os meta resync --yes`, the data commands' `--apply`) runs schema + // boot (the data commands' `--apply`) runs schema // sync — under `NODE_ENV=development` it would apply safe drift its // operator never confirmed. The step-down keeps the default; the self-heal // is a serving host's declaration. diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index 0d0a5237658..4c790c33a4f 100644 --- a/skills/objectstack-platform/references/operations.md +++ b/skills/objectstack-platform/references/operations.md @@ -92,7 +92,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): |:--------|:-------------| | `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | | `os diff` | Compare two ObjectStack config files and detect breaking changes | -| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | +| `os meta register` | Register (create/update) metadata on a target server (there is no `os meta apply`) | | `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | ## Environments & deploy From 7f185509939c67088c4934b3b0f4320147b7c40f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:10:15 +0000 Subject: [PATCH 03/25] wip(examples,plugin-security): delete the example binders; drift_status retires with its writer (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- content/docs/permissions/authorization.mdx | 12 +- content/docs/permissions/permission-sets.mdx | 62 +++------ .../areas/access-security.json | 2 +- docs/qa/platform-checklist/areas/search.json | 2 +- examples/app-crm/objectstack.config.ts | 10 -- .../src/security/bind-position-sets.ts | 121 ----------------- .../app-crm/test/position-bindings.test.ts | 18 +-- examples/app-showcase/objectstack.config.ts | 4 - examples/app-showcase/src/data/seed/index.ts | 10 +- .../src/security/bind-position-sets.ts | 123 ------------------ .../src/security/permission-sets.ts | 4 +- .../app-showcase/src/security/positions.ts | 7 +- .../src/security/seed-approval-demo.ts | 6 +- .../app-showcase/test/inert-wirings.test.ts | 18 ++- .../src/objects/rbac-objects.test.ts | 14 -- .../src/objects/sys-permission-set.object.ts | 62 +-------- 16 files changed, 62 insertions(+), 413 deletions(-) delete mode 100644 examples/app-crm/src/security/bind-position-sets.ts delete mode 100644 examples/app-showcase/src/security/bind-position-sets.ts diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 8d5d9470c32..c192857ff4e 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -152,13 +152,11 @@ them, and treat "sensitive field on a public object" as a review smell. The metadata↔config boundary follows one line: **definitions travel with the package (metadata); subject bindings and env-specific values stay as config.** -- A package ships **its own permission sets** (Shape B), recorded with - `managedBy: 'package'` + owning `packageId` on `sys_permission_set` - (`package_id` / `managed_by` columns). `bootstrapDeclaredPermissions` - (`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts`) - seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it - **never clobbers** env-authored (`platform`/`user`/legacy) rows. A package - never writes into a foreign record. +- A package ships **its own permission sets** (Shape B): its + `stack.permissions` are registry items stamped with the owning package + (`_packageId`), served by name from the security catalog (ADR-0131 D3). The + boot writes no `sys_permission_set` row for them, and a package never + writes into a foreign definition. - The environment admin **assigns** sets to positions/users; the runtime unions them. One shared set with hand-picked cross-package grants remains an env-admin-only construct. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index e56b6024100..cabc86aee13 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -161,8 +161,9 @@ the anchor decides *which* unit `unit*` means. ## Built-in permission sets -Auto-seeded on boot by `plugin-security` -(`objects/default-permission-sets.ts`): +Declared by `plugin-security` on its own manifest +(`objects/default-permission-sets.ts`) and served from the security catalog — +the boot writes no row for them: | Name | Scope | What it unlocks | Notes | |:--|:--|:--|:--| @@ -337,9 +338,10 @@ tenant-level. ## Provenance — package vs environment sets (ADR-0086) -A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded -idempotently at boot and re-seeded on upgrade; environment-authored sets -(`platform`/`user`) are never clobbered. The data layer refuses forging package +A package ships its own sets as registry items stamped with the owning package +(`packageId`), served from the security catalog — the boot writes no row for +them, and an upgrade is served as soon as the package registers its new +version; environment-authored sets are never clobbered. The data layer refuses forging package provenance through the admin door (two-doors separation, evolved by ADR-0094 — an edit of a set shipped in code is refused; clone it instead, see below), which is what makes package uninstall well-defined — and enforced: uninstalling a @@ -390,47 +392,21 @@ package and re-publish it (ADR-0086 two-doors). ### Declared ≠ enforced — diagnosing a frozen package set -A package-declared set's *enforced* grants (what `sys_permission_set` stores -and what the evaluator resolves) can diverge from its shipped *artifact* -(what the package currently declares) through two independent mechanisms — -either can be live on one row, and they need different remedies: +A package-declared set's *enforced* grants (what the security catalog serves +and the evaluator resolves) can diverge from its shipped *artifact* (what the +package currently declares) in one way: - **Overlay shadow.** An active `sys_metadata` overlay for the set's name (from a Studio save made while overlaying was still permitted, or through - the `OS_METADATA_WRITABLE` escape hatch) is re-projected onto the record on - every boot, unconditionally — the overlay wins forever, regardless of what - the package ships next. No door creates such an overlay any more: the - metadata door refuses a save over a packaged set, and so does the data door - (clone the set instead). **Remedy:** delete the overlay row. From the - release that adds ADR-0048's cold-boot check, a deployment that still holds - one does not boot: `os migrate security-catalog-overlays` lists every such - row with no server running, and `--apply` deletes them, after which the - package's definition is served again. -- **Provenance skip.** The record's `managed_by` column predates package - provenance tracking (a legacy insert without `managed_by: 'package'` — - typically a database first initialized on an older release line), so boot - seeding treats it as environment-authored and never reconciles it with the - package — permanently. There is currently no automated adoption path for - this case (boot-time auto-adoption and a bulk `os meta - adopt-permission-sets` command were both deliberately deferred as migration - machinery for a customer population that does not yet exist — see the - discussion on the tracking issue for this section). The manual recovery is - the same one-time SQL a field remediation used, run against the - environment's database with a backup first: - - ```sql - UPDATE sys_permission_set - SET managed_by = 'package', package_id = '' - WHERE name = ''; - ``` - - Restart the environment afterward so boot seeding re-adopts the row as - package-owned and reconciles its grants to the shipped artifact. - -Both mechanisms are surfaced on the record itself: `drift_status` -(`'overlay_shadow'` / `'provenance_skip'`) and `drift_detail` name the cause, -recomputed every boot, and the **Needs Attention** list view collects every -currently drifted set — an in-sync set is never listed there. + the `OS_METADATA_WRITABLE` escape hatch) answers ahead of the package's + definition — the overlay wins forever, regardless of what the package ships + next. No door creates such an overlay any more: the metadata door refuses a + save over a packaged set, and so does the data door (clone the set instead). + The boot names every such set in its log. **Remedy:** delete the overlay + row. From the release that adds ADR-0048's cold-boot check, a deployment + that still holds one does not boot: `os migrate security-catalog-overlays` + lists every such row with no server running, and `--apply` deletes them, + after which the package's definition is served again. --- diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 0034176ba3e..e379a6cbc9b 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -809,7 +809,7 @@ "app": "showcase", "requires": [ "showcase_inquiry is OWD private with create granted to the baseline — depth only matters where the baseline stops (permission-sets.ts docblock)", - "personas staffed by granting the sets (sys_user_permission_set) or assigning the bound positions manager/exec/ops (bind-position-sets.ts wires position → set at boot)" + "personas staffed by granting the sets (sys_user_permission_set) or assigning the positions manager/exec/ops (each declares its set in permissionSets, examples/app-showcase/src/security/positions.ts)" ] }, "steps": [ diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 4538380d3a8..f3c3120cc97 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -11,7 +11,7 @@ "provenance": "run #7629 (framework, 2026-08-11) drove search.rls-both-personas to 4/4 PASS on exactly this persona — 3 invoices visible to the member vs 15 to admin. Promoted from prose on that one item to a replayable area recipe by the 17.1.0 sweep (#10236 B1); the Mei/Ada measurements above are from #10257. Step 1 re-pointed invite-first by #21060 after QA run #21056 met the invite_only default (403 SELF_REGISTRATION_CLOSED on an uninvited sign-up).", "app": "showcase", "requires": [ - "a stock dev boot — the position→permission-set bindings are created automatically at startup on `kernel:bootstrapped`, including ['contributor','showcase_contributor'] (examples/app-showcase/src/security/bind-position-sets.ts, POSITION_PERMISSION_SET_BINDINGS). ⛔ Do NOT hand-create a sys_position_permission_set row: it already exists, and a duplicate proves nothing about the merge", + "a stock dev boot — the `contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", "system-context write access for the position binding (the sys_user_position insert) — the same pattern access-security.json's permission-set items already replay", "an isolated boot (own port + file DB, dogfood §0); export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL fact) so unrelated flow noise does not pollute the run", "an admin session to INVITE the step-1 address before step 1: POST /api/v1/auth/organization/invite-member {\"email\": \"\", \"role\": \"member\"}. The stock showcase declares no auth.audience, so its posture is the invite_only default, which answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED; a pending, unexpired sys_invitation row for the address is what admits it (an admin POST /api/v1/auth/admin/create-user is the other door, and answers mustChangePassword:true)" diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 8ff69b242ba..10ffc96fc6b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -18,7 +18,6 @@ import { SalesUserPermissionSet, GuestPortalProfile, } from './src/security/index.js'; -import { registerCrmPositionBindings } from './src/security/bind-position-sets.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; @@ -118,12 +117,3 @@ export default defineStack({ // Seed data data: CrmSeedData, }); - -/** - * [#8060] Ensure the persona position↔permission-set bindings exist after the - * security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors - * app-showcase's own `onEnable` wiring. - */ -export const onEnable = async (ctx: unknown): Promise => { - registerCrmPositionBindings(ctx as Parameters[0]); -}; diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts deleted file mode 100644 index e8b9944d199..00000000000 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#8060] Position ↔ permission-set bindings for the CRM example. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `sales-positions.ts`), and that is what the - * authorization resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet; it is deleted with the last of them, and - * `test/position-bindings.test.ts` holds its list equal to the declarations. - * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The - * history: bindings used to live only as `sys_position_permission_set` rows, - * and this app declared three positions - * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` - * permission set that never met — every persona silently degraded to the - * `everyone` baseline (probe-blocked on every CRM object) until an admin - * hand-assigned the set. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the - * name references cannot resolve. We play the admin's part imperatively — - * inserting each missing binding idempotently (dedup by position+set pair, - * stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only - * AFTER every `kernel:ready` handler (incl. the security bootstrap) has - * settled. - * - * `crm_sales_user` is deliberately NOT marked `isDefault`: that would - * auto-bind it to the `everyone` anchor and grant every user — including - * ones holding none of the three positions — full CRUD on every CRM object, - * which changes the example's security story instead of completing it - * (ruling on #8060). Only the three declared positions get the set. - */ - -export const BINDINGS: ReadonlyArray = [ - ['sales_rep', 'crm_sales_user'], - ['sales_manager', 'crm_sales_user'], - ['finance_approver', 'crm_sales_user'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerCrmPositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar (never true for the - // real runtime `PluginContext`, which always provides one — this branch is - // unreachable in practice, so a microtask deferral is equivalent to the - // macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient - // `setTimeout`/`window`/`node` global: `examples/**` sources reachable from - // `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT - // tsconfig, which declares neither `dom` nor `types: ["node"]`. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - void Promise.resolve().then(run); - } -} diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts index 7756be56f1f..68ea7cead93 100644 --- a/examples/app-crm/test/position-bindings.test.ts +++ b/examples/app-crm/test/position-bindings.test.ts @@ -2,23 +2,23 @@ /** * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their - * positions (`permissionSets`), which the authorization resolver reads. The - * junction binder (`src/security/bind-position-sets.ts`) still writes the same - * pairs for the readers that have not moved off `sys_position_permission_set`; - * until it is deleted, the two must name the same pairs, or those readers - * judge a position by bindings the resolver does not grant. + * positions (`permissionSets`), which the authorization resolver reads; no + * junction row is written for them. Pinned literally so a persona losing its + * set is a red here, not a silently narrower boot. */ import { describe, expect, it } from 'vitest'; -import { BINDINGS } from '../src/security/bind-position-sets.js'; import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; describe('CRM position bindings', () => { - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares crm_sales_user', () => { const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) .sort(); - expect(declared).toHaveLength(3); - expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'finance_approver -> crm_sales_user', + 'sales_manager -> crm_sales_user', + 'sales_rep -> crm_sales_user', + ]); }); }); diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index ce5fefe511b..f1777eba834 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -18,7 +18,6 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; -import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; @@ -293,9 +292,6 @@ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. registerRecalcEndpoint(ctx as Parameters[0]); - // [#2926 ②] Ensure the persona position↔permission-set bindings exist after - // the security bootstraps (cannot be a seed — see bind-position-sets.ts). - registerShowcasePositionBindings(ctx as Parameters[0]); // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: // assign the dev admin the approver positions and launch the signoff flows so // real pending requests land in the inbox (cannot be a seed — see diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 3a24a642188..80abc28c1b5 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -258,12 +258,10 @@ const orgUnits = SeedSchema.parse({ ], }); -// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed -// loader runs before the security bootstrap creates the sys_position / -// sys_permission_set rows, so the required name references cannot resolve. -// They are ensured imperatively on kernel:bootstrapped instead (after every -// kernel:ready handler, incl. the security bootstrap, has settled) — see -// `src/security/bind-position-sets.ts` (wired via `onEnable`). +// [#2926 ②, ADR-0131 D3/D4] Position ↔ permission-set bindings are NOT seeded +// here: each position declares the sets it distributes in `permissionSets` +// (`src/security/positions.ts`), and the authorization resolver reads them +// from the definition — no binding row exists to seed. const teams = defineSeed(Team, { mode: 'upsert', diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts deleted file mode 100644 index 645ccd00166..00000000000 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `positions.ts`), and that is what the authorization - * resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet (the delegated-admin gate, the explainer, the sharing - * recipients); it is deleted with the last of them. - * `test/inert-wirings.test.ts` holds this list equal to the declarations. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name - * references cannot resolve and the required lookups fail validation. So we play - * the admin's part imperatively — inserting each missing binding idempotently - * (dedup by position+set pair, stable ids). - * - * Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in - * registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts - * BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before* - * the security bootstrap has created the position/set rows — the rows never - * appear from inside that hook. We therefore bind on **`kernel:bootstrapped`**, - * the anchor the kernel fires only AFTER every `kernel:ready` handler has - * settled (`kernel.ts` Phase 3.5 / `lite-kernel.ts`), so the bootstrap rows are - * guaranteed present. - * - * `everyone → showcase_member_default` is NOT bound here: the security plugin - * auto-binds the app's `isDefault` set (resolved as its `fallbackPermissionSet`) - * to `everyone` at boot. This list only carries the persona → set bindings the - * framework cannot infer. - */ - -/** - * The persona bindings as junction rows — the same pairs the position - * definitions declare (`inert-wirings.test.ts` pins the equality). - */ -export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ - ['contributor', 'showcase_contributor'], - ['manager', 'showcase_manager'], - ['exec', 'showcase_executive'], - ['auditor', 'showcase_auditor'], - ['ops', 'showcase_ops'], - ['field_ops_delegate', 'showcase_field_ops_delegate'], - ['client_liaison', 'showcase_client_liaison'], - ['client_portal_user', 'showcase_guest_portal'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerShowcasePositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of POSITION_PERMISSION_SET_BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: POSITION_PERMISSION_SET_BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - setTimeout(() => void run(), 0); - } -} diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 631edfd1a0d..62a413f870b 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -72,8 +72,8 @@ export const ContributorPermissionSet = definePermissionSet({ // // No policy below lists `positions`, deliberately. A set's row-level policies // are part of what the set grants, so they hold for EVERY holder of the set, - // whichever way it is held: through the `contributor` position binding - // (bind-position-sets.ts) or granted directly (the delegated admin below may + // whichever way it is held: through the `contributor` position's declared + // `permissionSets` (positions.ts) or granted directly (the delegated admin below may // hand this set out). `positions` narrows a policy to callers holding one of // the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set // every member resolves, not for a set that is itself the grant. diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 297cd2e2401..3a8d66a1bcb 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -10,10 +10,9 @@ * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * * Each position names the permission sets it distributes in `permissionSets` - * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. - * `src/security/bind-position-sets.ts` still writes the same bindings as - * `sys_position_permission_set` rows, for the readers that have not moved off - * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name, + * and no binding row is written for them. `test/inert-wirings.test.ts` pins + * the declared pairs. * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index a0d0b09a7e7..74ff671836d 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -18,9 +18,9 @@ * - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a * request can't be inserted through the generic data API either. * - * So we play the admin's part imperatively, exactly like `bind-position-sets.ts`: - * on `kernel:bootstrapped` (after the security bootstrap has created the - * position/permission rows and the automation engine is wired) we + * So we play the admin's part imperatively: on `kernel:bootstrapped` (after + * every `kernel:ready` handler has settled and the automation engine is + * wired) we * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 1af7f7ca9f0..c5111fab2c9 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -26,7 +26,6 @@ import { validateFunctionalCompleteness } from '@objectstack/lint'; import type { JobHandlerContext } from '@objectstack/runtime'; import { healthFor, sweepProjectHealth } from '../src/automation/jobs/index.js'; import * as jobsModule from '../src/automation/jobs/index.js'; -import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, @@ -705,10 +704,21 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 // readers that have not moved off `sys_position_permission_set`; until it is // deleted the two must name the same pairs, or those readers judge a // position by bindings the resolver does not grant. - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares the permission set its personas hold (ADR-0131 D3/D4)', () => { + // The position definition is the binding the resolver reads; no junction + // row is written for it. Pinned literally so a persona losing its set is a + // red here, not a silently narrower dogfood boot. const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); - expect(declared.length).toBeGreaterThan(0); - expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'auditor -> showcase_auditor', + 'client_liaison -> showcase_client_liaison', + 'client_portal_user -> showcase_guest_portal', + 'contributor -> showcase_contributor', + 'exec -> showcase_executive', + 'field_ops_delegate -> showcase_field_ops_delegate', + 'manager -> showcase_manager', + 'ops -> showcase_ops', + ]); }); it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index eb2f6c7155a..c84d8a3c06d 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -208,20 +208,6 @@ describe('RBAC object canonical names + row actions', () => { ]); }); - it('[field report — rc→GA declared≠enforced surfacing] SysPermissionSet.drift_status is a select naming the two detected causes, and appears in the "Needs Attention" list view', () => { - const f: any = (SysPermissionSet.fields as any).drift_status; - expect(f, 'drift_status field exists').toBeDefined(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['other', 'overlay_shadow', 'provenance_skip']); - - const views: any = SysPermissionSet.listViews; - expect(views.drifted, 'drifted list view exists').toBeDefined(); - expect(views.drifted.columns).toContain('drift_status'); - expect(views.all_permsets.columns).toContain('drift_status'); - }); - it('[A4 #2920] SysPermissionSet.managed_by is a select on the unified platform/package/admin vocab', () => { const f: any = (SysPermissionSet.fields as any).managed_by; expect(f, 'managed_by field exists').toBeDefined(); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index fe388882bfa..33eff4d50b1 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -166,32 +166,10 @@ export const SysPermissionSet = ObjectSchema.create({ data: { provider: 'object', object: 'sys_permission_set' }, // [ADR-0094] Surface provenance + the customized flag so admins can tell // a packaged set (and whether they've overlaid it) from an env set. - // [field report — rc→GA declared≠enforced surfacing] `drift_status` - // alongside them: `customized` alone stays `0` on a row whose - // `managed_by` is itself wrong (the provenance-skip + overlay-shadow - // confound), so it is not a substitute for this column. - columns: ['label', 'name', 'managed_by', 'customized', 'drift_status', 'active', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, - // [field report — rc→GA declared≠enforced surfacing] The LOUD surface: - // every package-declared set whose enforced grants differ from the - // shipped artifact, naming the set AND the cause (`drift_status`), - // updated every boot by `runPermissionSetDriftDiagnostics`. An in-sync - // set is never in this view — `drift_status` is written `null`, not - // `'in_sync'` (see `permission-set-drift.ts`), so this filter is a data - // fact, not merely a display choice: noise here is what would make the - // next real drift easy to ignore. - drifted: { - type: 'grid', - name: 'drifted', - label: 'Needs Attention', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'managed_by', 'drift_status', 'drift_detail', 'updated_at'], - filter: [{ field: 'drift_status', operator: 'is_not_null' }], - sort: [{ field: 'updated_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, }, fields: { @@ -337,44 +315,6 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Provenance', }), - // [field report — rc→GA declared≠enforced surfacing] Set when this - // package-declared set's ENFORCED grants differ from the shipped - // artifact — `runPermissionSetDriftDiagnostics` (permission-set-drift.ts) - // recomputes it every boot. Absent (null) means enforced == declared, OR - // this set is not currently package-declared — an in-sync set is never - // given a value here, on purpose (a badge that fires on every packaged - // set is noise, and noise is how the next real one gets ignored). - // ⚠️ NOT a substitute for, and not derived from, `customized`: that flag - // is forced `false` on a row whose `managed_by` is itself wrong (the - // provenance-skip + overlay-shadow confound measured on the field- - // reported environment), which is exactly the case this field still - // needs to catch. - drift_status: Field.select({ - label: 'Declaration Drift', - required: false, - readonly: true, - description: - "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + - "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + - "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + - "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + - "shipped artifact, or this set is not currently package-declared.", - options: [ - { value: 'overlay_shadow', label: 'Overlay shadow' }, - { value: 'provenance_skip', label: 'Provenance skip' }, - { value: 'other', label: 'Drift (other)' }, - ], - group: 'Provenance', - }), - - drift_detail: Field.textarea({ - label: 'Drift Detail', - required: false, - readonly: true, - description: 'Human-readable detail for `drift_status` — names the grant-count mismatch and its cause.', - group: 'Provenance', - }), - // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Permission Set ID', From 8d2baa41100c38e14fa264f1b587c4c103b543d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:12:53 +0000 Subject: [PATCH 04/25] wip: zero-row boot pin, everyone re-derivation pin, changeset (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 37 +++++ .../src/builtin-positions.boot.test.ts | 59 +++++--- ...ederivation-on-permission-mutation.test.ts | 138 ++++++++++++++++++ .../grant-permission-set-name.writers.test.ts | 4 +- 4 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 .changeset/15204-s6b2-declared-permission-seeders.md create mode 100644 packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md new file mode 100644 index 00000000000..bb0ec525e9b --- /dev/null +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -0,0 +1,37 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/cli': minor +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and `os meta resync` are deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a package-door publish, a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + +- **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. +- **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. +- **`os meta resync` is deleted.** It reconciled the platform-default `sys_permission_set` rows to the shipped declaration; the authorization resolver reads the declaration itself, so an edited default set is served at the next boot with no command. +- `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. +- In `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments no longer name `os meta resync`. Their behaviour is unchanged. + +Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + +- `bootstrapDeclaredPermissions` +- `normalizeManagedByVocab` +- `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` +- `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` + +FROM → TO: + +- FROM reading a platform default or a package's permission set from `sys_permission_set` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`. +- FROM `os meta resync` after editing a default set → TO restarting the server; the edited declaration is served as shipped. Remove the command from any script that calls it. +- FROM `bootstrapPlatformAdmin(ql, sets, { resync: true })` → TO `bootstrapPlatformAdmin(ql, sets)`; there is no row to reconcile. +- FROM reading `sys_permission_set.drift_status` / `drift_detail` or the **Needs Attention** view → TO the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). +- FROM importing any name in the list above → TO deleting the import; there is nothing to call in its place. 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 a0291771532..f24a9ed8a92 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -39,7 +39,9 @@ * ## The goldens * * 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 + * scenario, and an existing database keeps exactly the rows it had. Since + * stage 6b-2 no boot writes a `sys_permission_set` row either, and the grant + * golden holds without one. The census * instrument is proven able to see a row by a positive control (one * organization-authored create, read back as exactly one row). * @@ -183,6 +185,14 @@ interface Booted { ledger: string[]; /** The rows the boot left, one string per row. */ census: string[]; + /** [C3 stage 6b-2] The `sys_permission_set` rows the boot left, by name. */ + permissionSetCensus: string[]; +} + +/** Every `sys_permission_set` row's name and organization, past every scope. */ +async function permissionSetCensus(engine: ObjectQL): Promise { + const rows = (await engine.find('sys_permission_set', { where: {}, limit: 5000, context: SYS })) as any[]; + return rows.map((r) => `${r.name} | ${r.organization_id ?? '-'}`).sort(); } /** Every `sys_position` row, one string per row, past every scope. */ @@ -337,22 +347,18 @@ async function boot(scenario: Scenario): Promise { } recording = false; - return { engine, metadata, ledger, census: await positionCensus(engine) }; + return { + engine, metadata, ledger, + census: await positionCensus(engine), + permissionSetCensus: await permissionSetCensus(engine), + }; } /** The four principals' resolved grants — needs the organization and its memberships. */ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated'): Promise> { - const orgCtx = { isSystem: true, tenantId: ORG }; - // Walled: the organization's own copies of the sets its grants point at - // (the per-organization catalog), cloned from the platform bucket the boot - // just wrote — as the name-column equivalence suite does. - if (posture === 'isolated') { - for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); - const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; - await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); - } - } + // [C3 stage 6b-2] No `sys_permission_set` row exists in any posture: every + // grant below names its set, and the resolver takes the set from the + // security catalog (ADR-0131 D3/D4). // [ADR-0131 D4] The boot's organization-admin backfill already granted it by // name, in every posture — no organization copy of the set is needed — so // the reconcile finds nothing to do. @@ -360,13 +366,9 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated expect(reconciled.action).toBe('noop'); // The member's set, granted through the data door by an administrator. - const [viewer] = await engine.find('sys_permission_set', { - where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, - context: SYS, - }); await engine.insert( 'sys_user_permission_set', - { user_id: 'usr_member', permission_set: (viewer as any).name }, + { user_id: 'usr_member', permission_set: 'viewer_readonly' }, { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, ); @@ -498,6 +500,27 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(await positionCensus(engine)).toEqual(census); }); + // [C3 stage 6b-2] The catalog seeders are retired: no boot writes a + // `sys_permission_set` row — not the platform defaults, not a package's + // sets, not an organization's copies (the late organization included) — + // while the first platform administrator and the organization + // administrator are still granted (the grant golden below) by name. + it('writes no sys_permission_set row, and still grants the first platform admin and the organization admin', async () => { + const { engine, permissionSetCensus: rows } = booted.get(name)!; + expect(rows).toEqual([]); + const holders = (await engine.find('sys_user_permission_set', { where: {}, limit: 100, context: SYS })) as any[]; + const held = holders.map((g) => `${g.user_id} -> ${g.permission_set}`).sort(); + if (scenario.posture === 'single') expect(held).toContain('usr_admin -> admin_full_access'); + if (scenario.organizationAtBoot) { + expect(held.some((h) => h.startsWith('usr_orgadmin -> organization_admin'))).toBe(true); + } + // Positive control: the census sees a row — one insert reads back as one. + await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual(['zz_census_control | -']); + await engine.delete('sys_permission_set', { where: { id: 'ps_census_control' }, context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual([]); + }); + if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts new file mode 100644 index 00000000000..b7f3bd7b0be --- /dev/null +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22801 → A, C3 stage 6b-2] Every landed `permission` mutation derives the + * `everyone` anchor again. + * + * The derivation used to ride the `permission` publish materializer, which + * retired with the declared-permission seeder (ADR-0131 D3: no catalog row is + * written). It now hangs off the protocol's post-persistence mutation signal + * (`onMetadataMutation`), the one choke point every authoring door funnels + * through — and unconditionally on the body: whether an accepted set still + * qualifies is `readAcceptedEveryoneSets`' question, asked of what the catalog + * now serves, so a publish that drops `isDefault` or deletes the set stops it + * granting at once. + * + * Pinned at the wiring: which events re-derive, which do not, and that a + * bootstrap re-run (the first-user replay) never registers a second listener. + * What a derivation then declares is U2's own suite + * (`suggested-audience-bindings.test.ts`, the dogfood acceptance pin). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +afterEach(() => { vi.restoreAllMocks(); }); + +type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; + +/** Boot the real plugin over an in-memory engine, with a protocol that exposes the mutation signal. */ +async function boot() { + const tables: Record = {}; + const ql: any = { + registerMiddleware: () => {}, + // Only the identity objects the plugin's boot gate requires. + getSchema: (name: string) => + name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, + registry: { listItems: () => [] }, + find: async (object: string, opts?: any) => { + for (const k of Object.keys(opts?.where ?? {})) { + if (k.startsWith('$')) throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const rows = tables[object] ?? []; + // The caller's bound, applied BY PRESENCE (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows).map((r) => ({ ...r })); + }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return null; + }, + count: async () => 0, + insert: async (object: string, data: any) => { + const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; + (tables[object] ??= []).push(row); + return { ...row }; + }, + update: async (_object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return 0; + }, + }; + const listeners: MutationListener[] = []; + const protocol = { + // Present so the projection wiring takes its awaited seam and adds no + // listener of its own: every listener counted below is this stage's. + registerMutationProjector: vi.fn(), + onMetadataMutation: (listener: MutationListener) => { + listeners.push(listener); + return () => {}; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => null, list: async () => [] }, + protocol, + }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook, + }; + const derive = vi.spyOn(SecurityPlugin.prototype as any, 'declareEveryone').mockResolvedValue(undefined); + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + const fire = async (event: string) => { + for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb(); + }; + /** Let a fire-and-forget listener's promise chain settle. */ + const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; + return { ctx, fire, settle, listeners, derive }; +} + +describe('[#22801 → A] a landed permission mutation re-derives everyone', () => { + it('registers ONE listener, and a bootstrap re-run never adds a second', async () => { + const { ctx, fire, listeners } = await boot(); + await fire('kernel:ready'); + // Positive control: the bootstrap really completed. + expect( + ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), + ).toHaveLength(1); + expect(listeners).toHaveLength(1); + await fire('kernel:ready'); + expect(listeners).toHaveLength(1); + }); + + it('a published or deleted permission derives everyone again — whatever the body said', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(1); + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'deleted' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(2); + }); + + it('a draft, or a mutation of another type, derives nothing', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'draft' }); + listeners[0]!({ type: 'position', name: 'sales_rep', state: 'active' }); + listeners[0]!({ type: 'object', name: 'crm_lead', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index b34933df9e1..7262d5eb04b 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -93,8 +93,8 @@ describe('[ADR-0131 D4] the grant writers in plugin-security write the set by na expect(grants).toHaveLength(1); expectNamedOnly(grants[0], 'admin_full_access'); - // The holder is read by name: with every catalog row gone it still answers. - await engine.delete('sys_permission_set', { where: {}, multi: true, context: SYS }); + // [C3 stage 6b-2] The bootstrap wrote no catalog row, and the holder is + // read by name with none. expect(await engine.find('sys_permission_set', { context: SYS })).toEqual([]); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_first'); }); From facb9fd38f71f491c2314a4e9d2a4cd567f96b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:19:04 +0000 Subject: [PATCH 05/25] chore(plugin-security): regenerate the translation bundles without drift_status / drift_detail (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../src/translations/en.objects.generated.ts | 16 ---------------- .../src/translations/es-ES.objects.generated.ts | 16 ---------------- .../es-ES.source-hashes.generated.ts | 8 -------- .../src/translations/ja-JP.objects.generated.ts | 16 ---------------- .../ja-JP.source-hashes.generated.ts | 8 -------- .../src/translations/zh-CN.objects.generated.ts | 16 ---------------- .../zh-CN.source-hashes.generated.ts | 8 -------- 7 files changed, 88 deletions(-) diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index cfdc7def891..efbd77c2f66 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -241,19 +241,6 @@ export const enObjects: NonNullable = { label: "Customized", help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "Permission Set ID" }, @@ -273,9 +260,6 @@ export const enObjects: NonNullable = { }, all_permsets: { label: "All" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 745b3549a79..9e7c04cca4e 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -241,19 +241,6 @@ export const esESObjects: NonNullable = { label: "Personalizado", help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "ID de conjunto de permisos" }, @@ -273,9 +260,6 @@ export const esESObjects: NonNullable = { }, all_permsets: { label: "Todos" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index e092e39af7d..608ee0175cd 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index e8d0fda6fd1..d540be40ba6 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -241,19 +241,6 @@ export const jaJPObjects: NonNullable = { label: "カスタマイズ済み", help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "権限セット ID" }, @@ -273,9 +260,6 @@ export const jaJPObjects: NonNullable = { }, all_permsets: { label: "すべて" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index e60a8172406..51efd73cf9b 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 093d7714a18..4859358e9c9 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -241,19 +241,6 @@ export const zhCNObjects: NonNullable = { label: "已定制", help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "权限集 ID" }, @@ -273,9 +260,6 @@ export const zhCNObjects: NonNullable = { }, all_permsets: { label: "全部" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index 64766ab31ac..f97c9467f5f 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", From 47f0b55d583880c85479036a8e1474225edc54ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:27:01 +0000 Subject: [PATCH 06/25] test(plugin-security): fixtures modelling an existing database write the legacy platform-bucket rows themselves (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../__tests__/legacy-catalog-rows.testkit.ts | 58 +++++++++++++++++++ ...latform-admin-authenticable-target.test.ts | 3 + ...platform-admin-promotion-selection.test.ts | 3 + ...grant-permission-set-name-backfill.test.ts | 3 + ...nt-permission-set-name.equivalence.test.ts | 3 + .../src/grant-readers-by-name.golden.test.ts | 3 + .../src/grant-readers-unnamed-grant.test.ts | 11 ++++ ...lve-authz-grant-set-by-name.golden.test.ts | 3 + .../resolve-authz-grant-set-by-name.test.ts | 3 + 9 files changed, 90 insertions(+) create mode 100644 packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts new file mode 100644 index 00000000000..f73aed75f95 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the organization-less `sys_permission_set` rows an EARLIER + * release's platform-admin bootstrap seeded for the platform's default sets + * (the "platform bucket"). + * + * [C3 stage 6b-2] No boot writes these rows any more (ADR-0131 D3: the sets are + * served from the security catalog, and a grant names its set). A suite that + * models an EXISTING database — a grant written before the name column, whose + * `permission_set_id` points at one of these rows; an organization's copy cloned + * from the bucket — writes them itself, here, in the shape the retired seeder + * wrote: `managed_by: 'platform'`, active, the set's facets as JSON columns. + * Idempotent: a name that already has an organization-less row is left alone. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; + +/** The legacy row id for a default set — stable, so a fixture can point a grant at it. */ +export function legacyBucketRowId(name: string): string { + return `ps_bucket_${name}`; +} + +/** Write the legacy platform-bucket row for each set; returns `name → id`. */ +export async function seedLegacyPlatformBucket( + engine: { find: (...args: any[]) => Promise; insert: (...args: any[]) => Promise }, + sets: readonly PermissionSet[], +): Promise> { + const ids: Record = {}; + for (const ps of sets) { + if (!ps.name) continue; + const existing = ((await engine.find('sys_permission_set', { where: { name: ps.name }, context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own?.id) { + ids[ps.name] = String(own.id); + continue; + } + const id = legacyBucketRowId(ps.name); + await engine.insert('sys_permission_set', { + id, + name: ps.name, + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + active: true, + managed_by: 'platform', + }, { context: SYS }); + ids[ps.name] = id; + } + return ids; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 42c76eb2cad..bfcac098284 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -50,6 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -319,6 +320,8 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC // written by hand — which is exactly the row the old selector would have // picked. const { logger: seedLogger } = collectingLogger(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const seedPass = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger: seedLogger, }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 0d0fec1689c..095c5a6c164 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -70,6 +70,7 @@ import { PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -744,6 +745,8 @@ describe('#16682 — the promotion target is chosen, not sampled', () => { // this change's to make. A declared owner must not move an existing grant. const engine = await boot(); await seedUser(engine as any, 'usr_person0', 'person0@demo.example', '2025-01-01T00:00:00.000Z', false); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const first = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); expect(first.reason).toBe('no_authenticable_user'); const sets = await findRows(engine, 'sys_permission_set', { name: 'admin_full_access' }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..9ba81844b02 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -34,6 +34,7 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -175,6 +176,8 @@ async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise< for (const ps of [...defaultPermissionSets, QA_ADMIN, OWN_SET, OTHER_SET]) { engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const seeded = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(seeded.adminPromoted).toBe(false); // no user yet — the golden world promotes after its users exist diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..c170cf74428 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -44,6 +44,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -158,6 +159,8 @@ async function grantsByPrincipal(posture: Posture): Promise => describe('[ADR-0131 D4] a grant that names nothing — explain', () => { it('reports no dropped grant for it: an unnamed grant names no set to report', async () => { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_member', '2025-03-01T00:00:00.000Z'); await engine.insert('sys_permission_set', { id: 'ps_off', name: 'legacy_reports', label: 'Legacy' }, { context: SYS } as any); @@ -178,6 +181,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst */ async function standingAdminUnnamed(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); expect((await bootstrapPlatformAdmin(engine, defaultPermissionSets)).reason).toBe('no_users'); await user(engine, 'usr_oldest', '2024-01-01T00:00:00.000Z'); await user(engine, 'usr_admin', '2025-01-01T00:00:00.000Z'); @@ -192,6 +197,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('control — named, the grant is the holder: no promotion, and it names the claim target', async () => { const engine = await standingAdminUnnamed(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_admin' }); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_admin'); @@ -200,6 +207,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('unnamed, the promotion is WITHHELD — no second unscoped grant is minted for the oldest account', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report.adminPromoted).toBe(false); expect(report.reason).toBe('admin_grant_unnamed'); @@ -217,6 +226,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst describe('[ADR-0131 D4] a grant that names nothing — the organization-administrator reconcile', () => { async function orgAdmin(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_orgadmin', '2025-02-01T00:00:00.000Z'); await engine.insert( diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..e02cbb25708 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -38,6 +38,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -156,6 +157,8 @@ async function resolverByPrincipal(posture: Posture): Promise Date: Sun, 11 Oct 2026 19:27:46 +0000 Subject: [PATCH 07/25] docs(changeset): state the migration in prose under the runtime-interface-only disposition (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index bb0ec525e9b..553947e4d15 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -9,7 +9,7 @@ feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -28,10 +28,10 @@ Removed from `@objectstack/plugin-security`'s public exports, with no replacemen - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` -FROM → TO: +What to change: -- FROM reading a platform default or a package's permission set from `sys_permission_set` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`. -- FROM `os meta resync` after editing a default set → TO restarting the server; the edited declaration is served as shipped. Remove the command from any script that calls it. -- FROM `bootstrapPlatformAdmin(ql, sets, { resync: true })` → TO `bootstrapPlatformAdmin(ql, sets)`; there is no row to reconcile. -- FROM reading `sys_permission_set.drift_status` / `drift_detail` or the **Needs Attention** view → TO the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). -- FROM importing any name in the list above → TO deleting the import; there is nothing to call in its place. +- Read a platform default or a package's permission set through the security catalog (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`, not from `sys_permission_set`. +- Remove `os meta resync` from any script that calls it. After editing a default set, restart the server: the edited declaration is served as shipped. +- Call `bootstrapPlatformAdmin(ql, sets)` without the `resync` option; there is no row to reconcile. +- In place of `sys_permission_set.drift_status` / `drift_detail` and the **Needs Attention** view, read the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). +- Delete any import of the names listed above; there is nothing to call in their place. From 173f4c1f1f1554a822bcd5e95f80df6dc30c4393 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:28:01 +0000 Subject: [PATCH 08/25] docs(changeset): no-migration-prescription disposition (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .changeset/15204-s6b2-declared-permission-seeders.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index 553947e4d15..1f81ecb718d 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -9,7 +9,7 @@ feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -27,11 +27,3 @@ Removed from `@objectstack/plugin-security`'s public exports, with no replacemen - `normalizeManagedByVocab` - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` - -What to change: - -- Read a platform default or a package's permission set through the security catalog (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`, not from `sys_permission_set`. -- Remove `os meta resync` from any script that calls it. After editing a default set, restart the server: the edited declaration is served as shipped. -- Call `bootstrapPlatformAdmin(ql, sets)` without the `resync` option; there is no row to reconcile. -- In place of `sys_permission_set.drift_status` / `drift_detail` and the **Needs Attention** view, read the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). -- Delete any import of the names listed above; there is nothing to call in their place. From 6e67a32803ff376279aa28dfc0f202432cc45043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:31:02 +0000 Subject: [PATCH 09/25] chore: liveness verdicts and the tenant-audit census follow the retired seeders (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 16 ++++----- packages/spec/liveness/permission.json | 13 ++++--- .../spec/liveness/state-counts/permission.md | 2 +- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1d30a19aa13..e1d7b0d6079 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 44 of them — just under a fifth +typed `any` have no type to read, and there are 42 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 232 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 230 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 | **232** | +| 175 write call sites | quoted in the merged changeset | **230** | | 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 | **157 of 232** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 127 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 230** decidable, **73** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 97 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 44 sites reached +one, and both are counted in the generated tables below: the 42 sites reached through an erased (`any`) receiver, and the 52 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 -127 of 232 (55%) as decidably elevated, with 97 more whose elevation is a +125 of 230 (54%) as decidably elevated, with 97 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 / 232`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 230`, 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,17 +223,17 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **232** | +| write call sites on the application surface | **230** | | …whose object name is statically decidable | 157 | -| …whose object name is chosen at run time | 75 | +| …whose object name is chosen at run time | 73 | | …against an object with tenancy ENABLED | 156 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 169 | | 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 | 127 | +| threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | @@ -241,12 +241,12 @@ cannot read, and they are neither in nor out. | :--- | ---: | | receiver carried a readable engine type | 188 | | receiver erased, placed by the object NAME | 30 | -| receiver erased, placed by an `object: string` PARAMETER | 9 | +| receiver erased, placed by an `object: string` PARAMETER | 7 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 105 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 13 | +| object name is an `object: string` parameter | 11 | | object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `173f4c1f1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | 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 e296a8a5c67..4f190d5d320 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,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 232 | +| Write call sites | 230 | | Object name statically decidable | 157 | -| Object name chosen at run time | 75 | +| Object name chosen at run time | 73 | | Against a tenancy-enabled object | 156 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 169 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 127 | +| Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `173f4c1f1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -160,13 +160,11 @@ Measured on 2026-10-11 at `5d0a4a152`. | `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-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 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 2906221a2b9..f5aa8eb1fda 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -22,15 +22,14 @@ }, "packageId": { "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (author-declared fallback for the registry `_packageId` provenance; persisted to sys_permission_set.package_id)", - "note": "ADR-0086 D3 — owning package for a package-shipped set; proven by packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts." + "verifiedAt": "2026-10-11", + "evidence": "packages/cli/src/utils/permission-set-name-collisions.ts (`asId(ps.packageId)` — the author-declared owner a declared set is judged by at the compile-time name-collision door)", + "note": "ADR-0086 D3 — owning package for a package-shipped set. REPOINTED 2026-10-11 (#15204 stage 6b-2): the declared-permission seeder that read it as the `_packageId` fallback and persisted it to sys_permission_set.package_id is retired (ADR-0131 D3: the boot writes no catalog row); the compile-time collision door is the reader that remains." }, "managedBy": { - "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", - "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." + "status": "dead", + "verifiedAt": "2026-10-11", + "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user). VERDICT CORRECTED 2026-10-11 (#15204 stage 6b-2): no code reads the AUTHORED key. The cited consumer, bootstrapDeclaredPermissions, stamped `managed_by: 'package'` on every row it wrote and never read `ps.managedBy`, so this row's `live` verdict pointed at a writer of the COLUMN, not a reader of the key; that seeder is now retired (ADR-0131 D3: the boot writes no catalog row). The sys_permission_set.managed_by column is unaffected. Retirement of the authored key is an enforce-or-remove item for the spec seat." }, "adminScope": { "status": "live", diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md index 729ecb82e76..346eb180cfe 100644 --- a/packages/spec/liveness/state-counts/permission.md +++ b/packages/spec/liveness/state-counts/permission.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `permission` | 38 | 0 | 0 | 4 | 0 | 42 | +| `permission` | 37 | 0 | 0 | 5 | 0 | 42 | From c89e15de6b815dbd5623ef230c094f20db7ea69b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:35:19 +0000 Subject: [PATCH 10/25] chore: the engine-double ledgers drop the retired suites and pin the new one (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- ...ederivation-on-permission-mutation.test.ts | 32 ++++++++++++++---- scripts/engine-double-contract.baseline.json | 16 --------- scripts/engine-double-contract.pinned.json | 33 +++---------------- scripts/objectql-double-limit.baseline.json | 15 --------- 4 files changed, 30 insertions(+), 66 deletions(-) diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts index b7f3bd7b0be..4f0f4999f5a 100644 --- a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -27,6 +27,28 @@ afterEach(() => { vi.restoreAllMocks(); }); type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; +/** + * Field equality plus `{ $in: [...] }`. Anything else is REFUSED rather than + * answered — a combinator read as a field name matches no row and would report + * that as "no rows". + */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) { + throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const actual = row?.[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = Object.keys(v as Record); + if (ops.length === 1 && ops[0] === '$in') { + return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + } + throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); + } + return actual === (v ?? null); + }); +} + /** Boot the real plugin over an in-memory engine, with a protocol that exposes the mutation signal. */ async function boot() { const tables: Record = {}; @@ -37,12 +59,10 @@ async function boot() { name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, registry: { listItems: () => [] }, find: async (object: string, opts?: any) => { - for (const k of Object.keys(opts?.where ?? {})) { - if (k.startsWith('$')) throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } - const rows = tables[object] ?? []; - // The caller's bound, applied BY PRESENCE (`check:objectql-double-limit`). - return (typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows).map((r) => ({ ...r })); + const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); + // The caller's bound, applied BY PRESENCE and after the filter + // (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); }, findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index bdf92642bfb..e746851c221 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -384,22 +384,6 @@ "why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.", "closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.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/bootstrap-platform-admin.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 25. 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/permission-set-projection.test.ts", "verb": "delete", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8ff5b60bf3a..8f16f1590a8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3111,11 +3111,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/check-only-write-scope.test.ts", "verb": "delete", @@ -3172,22 +3167,22 @@ "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "findOne", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "update", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", "verb": "findOne", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", "verb": "update", "pinned": 1 }, @@ -3291,11 +3286,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/per-organization-catalog.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts", "verb": "delete", @@ -3316,11 +3306,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-drift.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", "verb": "findOne", @@ -3331,11 +3316,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-name-collision.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "findOne", @@ -3471,11 +3451,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 9a37c3fd650..dfdb07c3ca8 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -582,27 +582,15 @@ "packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { - "blind": 1 - }, - "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts": { "blind": 1 }, "packages/plugins/plugin-security/src/explain-engine.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/normalize-managed-by.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { "unjudged": 1 }, @@ -612,9 +600,6 @@ "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/permission-set-drift.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { "unjudged": 1 }, From 274040fb3763332bdd8898a257c43d6530e1899d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:45:49 +0000 Subject: [PATCH 11/25] docs(changeset): os meta resync and the example binders carry their own changeset (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 9 ++------- .../15204-s6b2a-meta-resync-example-binders.md | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 .changeset/15204-s6b2a-meta-resync-example-binders.md diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index 1f81ecb718d..97b7b7fbb06 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -1,15 +1,12 @@ --- '@objectstack/plugin-security': minor -'@objectstack/cli': minor -'@objectstack/driver-sql': patch -'@objectstack/runtime': patch --- -feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and `os meta resync` are deleted +feat(plugin-security)!: the boot writes no `sys_permission_set` row — the declared-permission seeder and the platform-default materialization are deleted Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -17,9 +14,7 @@ Clause-②: yes (narrowing) - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. -- **`os meta resync` is deleted.** It reconciled the platform-default `sys_permission_set` rows to the shipped declaration; the authorization resolver reads the declaration itself, so an edited default set is served at the next boot with no command. - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. -- In `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments no longer name `os meta resync`. Their behaviour is unchanged. Removed from `@objectstack/plugin-security`'s public exports, with no replacement: diff --git a/.changeset/15204-s6b2a-meta-resync-example-binders.md b/.changeset/15204-s6b2a-meta-resync-example-binders.md new file mode 100644 index 00000000000..eb592f48db0 --- /dev/null +++ b/.changeset/15204-s6b2a-meta-resync-example-binders.md @@ -0,0 +1,18 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-security': patch +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(cli)!: `os meta resync` is deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + +In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. From 7d870d107f127a7c7f4fa40957cf5de43fad7932 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:21:38 +0000 Subject: [PATCH 12/25] test(dogfood): fixtures read the security catalog, not a seeded sys_permission_set row; existing-database cases write the legacy row themselves (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../activity-field-values.dogfood.test.ts | 4 +- ...tivity-label-display-field.dogfood.test.ts | 4 +- .../activity-parent-read-gate.dogfood.test.ts | 5 +- .../activity-text-predicate.dogfood.test.ts | 4 +- .../activity-withheld-update.dogfood.test.ts | 5 +- ...n-ledger-decision-metadata.dogfood.test.ts | 4 +- ...-engine-middleware-objects.dogfood.test.ts | 5 +- ...oval-snapshot-masked-field.dogfood.test.ts | 5 +- .../qa/dogfood/test/armed.dogfood.test.ts | 22 +-- ...achments-permission-matrix.dogfood.test.ts | 7 +- ...ments-unscoped-delete-gate.dogfood.test.ts | 15 +- ...audit-log-audit-capability.dogfood.test.ts | 9 +- .../audit-log-field-values.dogfood.test.ts | 4 +- ...audit-log-parent-read-gate.dogfood.test.ts | 5 +- .../authored-row-write-scope.dogfood.test.ts | 5 +- .../dogfood/test/authz-conformance.matrix.ts | 6 +- .../test/bulk-widener-probe.dogfood.test.ts | 7 +- .../qa/dogfood/test/catalog-permission-set.ts | 63 +++++++ .../cbp-explain-master-write.dogfood.test.ts | 9 +- ...t-attachment-comment-gates.dogfood.test.ts | 5 +- ...comments-permission-matrix.dogfood.test.ts | 5 +- .../explain-write-door-parity.dogfood.test.ts | 5 +- ...tity-admin-fields-org-peer.dogfood.test.ts | 5 +- ...lic-read-write-write-floor.dogfood.test.ts | 5 +- ...ner-anchor-and-bulk-writes.dogfood.test.ts | 5 +- ...-write-refusal-not-visible.dogfood.test.ts | 5 +- ...sion-set-assignment-window.dogfood.test.ts | 5 +- ...clone-boot-unowned-warning.dogfood.test.ts | 166 ------------------ ...on-set-lock-row-provenance.dogfood.test.ts | 7 +- ...te-through-package-binding.dogfood.test.ts | 4 + ...ite-unreadable-not-matched.dogfood.test.ts | 5 +- ...arch-companion-field-scope.dogfood.test.ts | 4 +- ...aring-rule-org-less-caller.dogfood.test.ts | 5 +- ...howcase-agent-intersection.dogfood.test.ts | 5 +- ...se-client-liaison-fixtures.dogfood.test.ts | 5 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 5 +- .../showcase-expand-crud-gate.dogfood.test.ts | 5 +- ...owcase-fls-read-mask-strip.dogfood.test.ts | 5 +- ...ase-invoice-seed-isolation.dogfood.test.ts | 5 +- ...case-permission-projection.dogfood.test.ts | 6 + ...howcase-permission-seeding.dogfood.test.ts | 52 +++--- .../showcase-permission-zoo.dogfood.test.ts | 33 ++-- .../test/two-doors-permission.dogfood.test.ts | 42 +---- ...or-unreadable-is-not-found.dogfood.test.ts | 5 +- 44 files changed, 250 insertions(+), 337 deletions(-) create mode 100644 packages/qa/dogfood/test/catalog-permission-set.ts delete mode 100644 packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts diff --git a/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts b/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts index 7225eae5780..84287515912 100644 --- a/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts @@ -47,6 +47,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'afv_item'; const CAP_UNMASK = 'afv_unmask'; @@ -177,8 +178,7 @@ describe('[#21081] activity rows serve a parent field value only to a reader ser const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts b/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts index 6c8384ca065..89f1f76a088 100644 --- a/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts @@ -39,6 +39,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const CUSTOMER = 'ald_customer'; const NAMED = 'ald_named'; @@ -142,8 +143,7 @@ describe('[#21878] an activity row names its record by the ADR-0079 title, with token[who] = await stack.signUp(email); const userId = await idOf('sys_user', { email }); for (const name of PRINCIPALS[who]) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts index d1a4904ea5c..53dcb56c02a 100644 --- a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts @@ -25,6 +25,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const MEMBER_EMAIL = 'act-gate-member@verify.test'; @@ -63,8 +64,8 @@ describe('sys_activity: a member reads the activity of records it can read, and ql = await stack.kernel.getServiceAsync('objectql'); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER_EMAIL }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: activityReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, activityReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: set.name }, { context: { ...SYS } }); await assertArmed([ diff --git a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts index fbb46afc401..88bdaa74d05 100644 --- a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts @@ -57,6 +57,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'atp_item'; const APPR = 'atp_appr'; @@ -267,8 +268,7 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } if (who === 'control') { diff --git a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts index be764597bf5..b460b041219 100644 --- a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts @@ -33,6 +33,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SysUser } from '@objectstack/platform-objects/identity'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DOMAIN = 'withheld-update-21388.verify.test'; @@ -112,8 +113,8 @@ describe('[#21388] an org peer is withheld the activity rows of a colleague’s uid[who] = String((await plain('sys_user', { email }, 1))[0]?.id ?? ''); await memberOf(uid[who], 'member'); } - const [activitySet] = await plain('sys_permission_set', { name: activityReadSet.name }, 1); - expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + const activitySet = await catalogPermissionSet(ql, activityReadSet.name); + expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const who of ['member', 'admin']) { await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); } diff --git a/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts index 35bb018d99d..69ed72cef8e 100644 --- a/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts @@ -71,6 +71,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const LEDGER = 'sys_audit_log'; const USER = 'sys_user'; @@ -264,8 +265,7 @@ describe('the admin identity rows on the compliance ledger carry decisions, neve token[who] = await stack.signUp(email); const userId = await idOf(USER, { email }); for (const name of READERS[who]) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts b/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts index a32d659a504..eacbc805faf 100644 --- a/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts @@ -45,6 +45,7 @@ import { FILE_BYTES, type AttachmentsHarness, } from './fixtures/attachments-authz-harness.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; type Row = Record; @@ -124,8 +125,8 @@ describe('[#21080] comment threads and activity rows: the analytics door answers adminTok = await stack.signIn(); memberTok = await stack.signUp('mw-objects-member@verify.test'); const memberId = (await ql.findOne('sys_user', { where: { email: 'mw-objects-member@verify.test' }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: activityReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, activityReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts index 9a8888c705e..7421d33b481 100644 --- a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts @@ -42,6 +42,7 @@ import { defineStack, defineFlow } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJECT = 'snapmask_item'; const POSITION = 'snapmask_reviewer'; @@ -197,8 +198,8 @@ describe('[#20964] approval snapshot field served masked on the data plane', () await ql.insert('sys_position', { id: 'pos_snapmask', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_m', user_id: memberId, position: POSITION, organization_id }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_u', user_id: unmaskerId, position: POSITION, organization_id }, SYS); - const unmask = await ql.findOne('sys_permission_set', { where: { name: unmaskSet.name }, context: { isSystem: true } }); - expect(unmask?.id, 'fixture permission set seeded').toBeTruthy(); + const unmask = await catalogPermissionSet(ql, unmaskSet.name); + expect(unmask, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [unmaskerId, submitterId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmask.name }, SYS); } diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index e708393c9fc..36aade5c521 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -238,10 +238,12 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('[control] finds a set that really seeded — so its negative reading is a real negative', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a permission set that failed to seed grants the principal nothing', + // [C3 stage 6b-2] A row that really landed: the member this suite + // signed up. No boot writes a `sys_permission_set` row any more. + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }).read(); expect(verdict.armed).toBe(true); expect(verdict.observed).toContain('is seeded'); @@ -262,8 +264,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED when the row exists but fails its own predicate', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, armed: () => false, control: 'a seeded row that must also carry something', disarmedBy: 'the row seeded but not the part the control rides on', @@ -383,10 +385,10 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( }), seededArmed({ stack: inside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a set that failed to seed grants the principal nothing', + object: 'sys_user', + where: { email: 'armed-inside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }), ]), ).resolves.toBeUndefined(); diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index f1e1fe3d5bc..1d072ec2a10 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -46,6 +46,7 @@ import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/ import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js'; import { assertArmed, principalArmed } from './armed.js'; import { SysAttachment } from '@objectstack/platform-objects/audit'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DAY_MS = 86_400_000; @@ -163,8 +164,8 @@ describe('attachments permission matrix (#2755)', () => { // Domain grant (see attachmentManagerSet): memberA/memberB manage // attachments; the baseline member deliberately keeps only the // `member_default` everyone-anchor baseline (no delete bit anywhere). - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } @@ -719,7 +720,7 @@ describe('attachments without service-storage (a′)', () => { const editorTok = await stack.signUp('att-nostorage-b@verify.test'); const uploaderId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-a@verify.test' }, context: SYS }))?.id; const editorId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-b@verify.test' }, context: SYS }))?.id; - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); for (const userId of [uploaderId, editorId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts index fd05f397eea..cf672abbae9 100644 --- a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts @@ -58,6 +58,7 @@ import { FILE_BYTES, type AttachmentsHarness, } from './fixtures/attachments-authz-harness.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -116,11 +117,8 @@ describe('sys_attachment delete gate under an unscoped multi-delete (#9483)', () // The delete BIT first. Without it RBAC refuses ahead of the attachment // hook with PERMISSION_DENIED, and every assertion below would be green for // the wrong reason — a denial from a layer this file is not about. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, @@ -311,11 +309,8 @@ describe('sys_attachment unscoped multi-delete is refused on its SHAPE, not on e // Same RBAC premise as the block above: without the delete bit the refusals // below would come from RBAC, about a different question entirely. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 4cbacd32505..ece97fc5786 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -45,6 +45,7 @@ import { defineStack, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ const CAP = 'view_all_audit_log'; @@ -178,8 +179,8 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren const userIdOf = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, context: { ...SYS } }))?.id ?? ''); const grant = async (email: string, setName: string) => { - const set = await ql.findOne('sys_permission_set', { where: { name: setName }, context: { ...SYS } }); - expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, setName); + expect(set, `fixture permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await userIdOf(email), permission_set: set.name }, { context: { ...SYS } }); }; for (const who of ['holder', 'reader', 'writer'] as const) { @@ -406,8 +407,8 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga expect((await stack.apiAs(tok[who], 'POST', '/auth/organization/set-active', { organizationSlug: `alc-org-${who}` })).status).toBe(200); } const userA = await ql.findOne('sys_user', { where: { email: 'alc-org-a@verify.test' }, context: { ...SYS } }); - const set = await ql.findOne('sys_permission_set', { where: { name: capOnly.name }, context: { ...SYS } }); - await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: set.name }, { context: { ...SYS } }); + expect(await catalogPermissionSet(ql, capOnly.name), 'fixture permission set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: capOnly.name }, { context: { ...SYS } }); for (const who of ['a', 'b'] as const) { const res = await stack.apiAs(tok[who], 'POST', `/data/${OBJ}`, { name: `gone ${who}` }); expect(res.status).toBeLessThan(300); diff --git a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts index 159cf83fcd6..c01ed260ce8 100644 --- a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts @@ -62,6 +62,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'alv_item'; const LEDGER = 'sys_audit_log'; @@ -188,8 +189,7 @@ describe('[#21155] the compliance ledger serves a parent field value only to a r const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts index c9ac967acd0..6baff4bef46 100644 --- a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts @@ -38,6 +38,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LEDGER = 'sys_audit_log'; @@ -81,8 +82,8 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i ql = await stack.kernel.getServiceAsync('objectql'); const readerId = (await ql.findOne('sys_user', { where: { email: READER_EMAIL }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: ledgerReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, ledgerReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: readerId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index ea5c7c0de20..05582b0aa81 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -76,6 +76,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the two objects under probe ──────────────────────────────────────────── @@ -229,8 +230,8 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller expect(aliceId).toBeTruthy(); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, { context: { ...SYS } }); }; diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index b7c207aeec0..07075c06151 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -406,9 +406,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ 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 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.' }, + { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets served by the security catalog with their owning package (no row)', state: 'enforced', + enforcement: 'core createSecurityCatalogReader (permission sets from the registry, `_packageId` the owner, ADR-0131 D3); the boot writes no sys_permission_set row (C3 stage 6b-2 retired ADR-0086 D5\'s seeding)', proof: 'showcase-permission-seeding.dogfood.test.ts', + note: 'Was the ADR-0086 D5 seeding pin — declared sets materialized as managed_by:package records. ADR-0131 D3 gives the catalog one home, the registry, and the proof now pins that a declared set is served from it with its package while no row is written.' }, { id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_position, decoupled from better-auth membership)', state: 'enforced', enforcement: 'runtime/resolve-execution-context.ts reads sys_user_position (union sys_member.role) — ADR-0057 D4' }, diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 3ea631d7122..2f6053e5831 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -58,6 +58,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the app under probe ──────────────────────────────────────────────────── @@ -188,10 +189,8 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => expect(bobId).toBeTruthy(); expect(aliceId).toBeTruthy(); - const setRow = await ql.findOne('sys_permission_set', { - where: { name: 'probe_widener' }, context: { ...SYS }, - }); - expect(setRow?.id, 'the app-declared widener set is seeded').toBeTruthy(); + const setRow = await catalogPermissionSet(ql, 'probe_widener'); + expect(setRow, 'the app-declared widener set is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: bobId, permission_set: setRow.name }, { context: { ...SYS } }); diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts new file mode 100644 index 00000000000..b8a036cc26b --- /dev/null +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, C3 stage 6b-2] Does the security catalog serve this permission + * set — the fixture precondition that replaced "is its `sys_permission_set` + * row seeded". + * + * No boot writes a `sys_permission_set` row any more: a set is served by name + * from the registry, and a grant names its set. So a fixture that grants a set + * asks the catalog the security plugin bound to its engine — the read the + * authorization resolver itself uses — and gets the definition back, or + * `undefined` when no reader holds the name. + */ + +import { securityCatalogReaderOf } from '@objectstack/core'; + +export async function catalogPermissionSet( + ql: unknown, + name: string, +): Promise> | undefined> { + const reader = securityCatalogReaderOf(ql); + if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); + return (await reader.resolve('permission', name))?.definition; +} + +/** + * The `sys_permission_set` row an EARLIER release's boot wrote for a declared + * set, written here by a fixture that models an EXISTING database (the data + * door's edit, reset and lock paths still run against such rows until the + * object retires). The body is the catalog definition's, in the column shape + * the retired seeders wrote; `managedBy` / `packageId` are the provenance they + * stamped (`'package'` with the owning package, `'platform'` for a platform + * default). Idempotent: an existing organization-less row is returned as is. + */ +export async function ensureLegacyPermissionSetRow( + ql: any, + name: string, + provenance: { managedBy: 'package' | 'platform' }, +): Promise { + const SYS = { isSystem: true }; + const existing = ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own) return own; + const def: any = await catalogPermissionSet(ql, name); + if (!def) throw new Error(`the security catalog serves no permission set '${name}'`); + await ql.insert('sys_permission_set', { + id: `ps_legacy_${name}`, + name, + label: def.label ?? name, + description: def.description ?? null, + object_permissions: JSON.stringify(def.objects ?? {}), + field_permissions: JSON.stringify(def.fields ?? {}), + system_permissions: JSON.stringify(def.systemPermissions ?? []), + row_level_security: JSON.stringify(def.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(def.tabPermissions ?? {}), + admin_scope: def.adminScope ? JSON.stringify(def.adminScope) : null, + active: true, + managed_by: provenance.managedBy, + ...(provenance.managedBy === 'package' && typeof def._packageId === 'string' ? { package_id: def._packageId } : {}), + }, { context: SYS }); + return ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) + .find((r: any) => !r.organization_id); +} diff --git a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts index aa8beceffe8..7c4adcbda1e 100644 --- a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts @@ -68,6 +68,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -257,8 +258,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do memberId = await uid('cpe-member@verify.test'); stewardTok = await stack.signUp('cpe-steward@verify.test'); stewardId = await uid('cpe-steward@verify.test'); - const stewardSet = await ql.findOne('sys_permission_set', { where: { name: contractStewardSet.name }, context: SYS }); - expect(stewardSet?.id, 'fixture permission set seeded').toBeTruthy(); + const stewardSet = await catalogPermissionSet(ql, contractStewardSet.name); + expect(stewardSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: stewardId, permission_set: stewardSet.name }, { context: { ...SYS } }); await assertArmed([ @@ -472,8 +473,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do beforeAll(async () => { boardStewardTok = await stack.signUp('cpe-board-steward@verify.test'); const boardStewardId = await uid('cpe-board-steward@verify.test'); - const set = await ql.findOne('sys_permission_set', { where: { name: boardStewardSet.name }, context: SYS }); - expect(set?.id, 'board steward permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, boardStewardSet.name); + expect(set, 'board steward permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: boardStewardId, permission_set: set.name }, { context: { ...SYS } }); await assertArmed([ principalArmed({ diff --git a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts index 7c4d31b936c..eb928317827 100644 --- a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts @@ -41,6 +41,7 @@ import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { cpgStack, cpgSecurity } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -161,8 +162,8 @@ describe('controlled_by_parent parent gates: sys_attachment and sys_comment judg adminId = await uid('admin@objectos.ai'); memberId = await uid('cpg-member@verify.test'); - const domainSet = await ql.findOne('sys_permission_set', { where: { name: 'cpg_files_and_threads' }, context: SYS }); - expect(domainSet?.id, 'fixture permission set seeded').toBeTruthy(); + const domainSet = await catalogPermissionSet(ql, 'cpg_files_and_threads'); + expect(domainSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: domainSet.name }, { context: { ...SYS } }); await assertArmed([ diff --git a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts index 090ad97fd1a..640ef281203 100644 --- a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts @@ -70,6 +70,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { commentsFixtureStack, commentsFixtureSecurity } from './fixtures/comments-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('sys_comment permission matrix (#4630)', () => { // Domain grant (see commentManagerSet): both members may DELETE comments; // the everyone-anchor baseline carries no delete bit. - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'cmt_comment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'cmt_comment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts index 66707bf01af..7a82bca17da 100644 --- a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts @@ -57,6 +57,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -205,8 +206,8 @@ describe('security/explain answers every write verb as its door does, on read-ab for (const { axis, set, email } of AXES) { const token = await stack.signUp(email); const userId = await uid(email); - const row = await ql.findOne('sys_permission_set', { where: { name: set.name }, context: SYS }); - expect(row?.id, `${set.name} seeded`).toBeTruthy(); + const row = await catalogPermissionSet(ql, set.name); + expect(row, `${set.name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: row.name }, { context: { ...SYS } }); tokens[axis] = token; } diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index 1232173b95b..d436351cdb0 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -47,6 +47,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SysUser } from '@objectstack/platform-objects/identity'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DOMAIN = 'org-peer-21237.verify.test'; @@ -149,8 +150,8 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a await memberOf(uid[who], role); } - const [activitySet] = await findRows(ql, 'sys_permission_set', { name: activityReadSet.name }, 1); - expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + const activitySet = await catalogPermissionSet(ql, activityReadSet.name); + expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const who of ['member', 'owner', 'admin']) { await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); } diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index 425c8db9a82..cf471402372 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -54,6 +54,7 @@ import { resolveAuthzContext } from '@objectstack/core'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** * [#8074] The control every case below measures, and the default that silences @@ -226,8 +227,8 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el const carolId = await uid('owdw-carol@verify.test'); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 3b76301ed6c..d78bc2a21d7 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -33,6 +33,7 @@ import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = '/data/showcase_private_note'; const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; @@ -97,8 +98,8 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => // Direct per-user grant of the delete set (never anchor-bound). const SYS = { isSystem: true } as const; - const delSet = await ql.findOne('sys_permission_set', { where: { name: 'anchor_note_delete' }, context: SYS }); - expect(delSet?.id, 'declared delete set seeded').toBeTruthy(); + const delSet = await catalogPermissionSet(ql, 'anchor_note_delete'); + expect(delSet, 'declared delete set served by the security catalog').toBeTruthy(); for (const userId of [aliceId, bobId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: delSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index 43c4afcd889..979c594a2f7 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -58,6 +58,7 @@ import { AuditPlugin } from '@objectstack/plugin-audit'; import { buildAttSecret, buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet } from './fixtures/attachments-fixture.js'; import { buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -131,8 +132,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts index ff3e15c37fe..b017b6e7166 100644 --- a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const GRANTS = 'sys_user_permission_set'; @@ -80,8 +81,8 @@ describe('a permission-set assignment grants only inside its validity window (AD adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); - expect(set?.id, `the showcase seeded ${SET}`).toBeTruthy(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the showcase served by the security catalog ${SET}`).toBeTruthy(); for (const who of ['bare', 'open', 'before', 'inside', 'after', 'edited', 'falls', 'rises']) await sign(who); const now = Date.now(); diff --git a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts deleted file mode 100644 index 5a897849fe8..00000000000 --- a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21669] A permission set the environment CLONED is not reported, on the next -// boot, as a package declaration with no owner — over the real showcase -// composition, across a cold boot on one database file. -// -// ## What was broken -// -// The declared-permission boot loop (`bootstrapDeclaredPermissions`, -// plugin-security) walks every `permission` item in the engine registry. That -// registry holds more than package declarations: `loadMetaFromDb` hydrates every -// env-wide `sys_metadata` `permission` row into the same collection, and a set -// made with Setup's Clone action is one — with no package id, because it has -// none. The loop judged "unowned" before it looked at the row, so every boot -// (and every `metadata:reloaded`) logged -// `[permission_set_declaration_unowned] … the Setup admin surface … cannot see -// this set` for a set whose `managed_by:'admin'` row Setup lists and edits. -// Measured on `origin/main` before the fix through this file's own steps: one -// such line naming the clone, `skippedUnowned: 1`. -// -// ## Why a booted stack, booted twice -// -// The unit pins (`bootstrap-declared-permissions.test.ts`, `[#21669]`) hand the -// loop the item shape this boot produces. What they cannot show is that the -// real boot puts the clone into the walk at all — that is a property of the -// hydration, not of the loop, and only a restart on the same file shows it. So -// this file asserts the walk holds the clone with no package id BEFORE it -// asserts the warning is absent; without that precondition the absence would be -// vacuous. -// -// ## The clone is made the way the Setup dialog makes it -// -// The payload is built from the shipped `clone_permission_set` action's own -// declaration — its `target`, its `bodyExtra` and its `params` (the typed -// `label`/`name`, and every `defaultFromRow` facet copied from the base row) — -// and POSTed to the data door as the signed-in admin. Read, never restated: a -// facet added to the action reaches this pin without an edit here. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { securityObjects } from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const TOKEN = 'permission_set_declaration_unowned'; -/** A set the showcase package ships (`com.example.showcase`) — the clone's base. */ -const BASE = 'showcase_contributor'; -/** A name no package ships. */ -const CLONE = 'showcase_contributor_local_21669'; -const API_BASE = '/api/v1'; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ -async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { - const lines: string[] = []; - const stdout = process.stdout.write.bind(process.stdout); - const stderr = process.stderr.write.bind(process.stderr); - const warn = console.warn; - (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; - (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; - console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; - try { - return { value: await run(), lines }; - } finally { - (process.stdout as any).write = stdout; - (process.stderr as any).write = stderr; - console.warn = warn; - } -} - -describe('[#21669] a cloned permission set boots without the unowned-declaration warning (showcase, cold boot)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - /** Everything boot 2 wrote. */ - let bootLines: string[] = []; - /** Boot 2's engine. */ - let ql: any; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21669-')); - dbFile = join(dir, 'showcase.db'); - - // ── boot 1: clone a packaged set through the Setup dialog's own path ── - stack = await bootShowcase({ databaseFile: dbFile }); - const token = await stack.signIn(); - const engine: any = await stack.kernel.getServiceAsync('objectql'); - const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS); - expect(base?.managed_by, 'the base is a package-declared set').toBe('package'); - - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (local)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const created = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Harness health: the clone is the environment's own set — the row - // Setup lists, and the env-wide metadata row the next boot hydrates. - const [clone] = await engine.find('sys_permission_set', { where: { name: CLONE }, limit: 1 }, SYS); - expect({ managed_by: clone?.managed_by, package_id: clone?.package_id ?? null }) - .toEqual({ managed_by: 'admin', package_id: null }); - const stored = await engine.find('sys_metadata', { where: { type: 'permission', name: CLONE } }, SYS); - expect(stored.map((r: any) => ({ state: r.state, organization_id: r.organization_id ?? null }))) - .toEqual([{ state: 'active', organization_id: null }]); - await stack.stop(); - stack = undefined; - - // ── boot 2: same file, nothing authored ─────────────────────────────── - const second = await captureOutput(() => bootShowcase({ databaseFile: dbFile })); - stack = second.value; - bootLines = second.lines; - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the boot loop walks the clone, with no package id', () => { - // ⛔ Without this the absence below proves nothing: a boot that stopped - // hydrating the clone would also log no warning for it. - const item = (ql.registry.listItems('permission') ?? []).find((i: any) => i?.name === CLONE); - expect(item, `${CLONE} is in the registry the loop walks`).toBeDefined(); - expect(item._packageId ?? item.packageId ?? null).toBeNull(); - }); - - it('control: the capture saw the loop report its pass', () => { - // The seeding pass logs one summary line per run, through the same - // logger the warning used — so an empty capture cannot pass the next case. - expect(bootLines.some((l) => l.includes('declared permission sets seeded into sys_permission_set'))).toBe(true); - }); - - it('the cloned set is not reported as an unowned declaration', () => { - const unowned = bootLines.filter((l) => l.includes(TOKEN) && l.includes(CLONE)); - expect(unowned).toEqual([]); - }); -}); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts index 72ccea5f54a..fd675b10c3c 100644 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts @@ -62,6 +62,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -163,7 +164,11 @@ describe('[#21789] the permission-set lock reads the row\'s provenance — the t expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. + // is built from the shipped action's own declaration. [C3 stage 6b-2] + // On an EXISTING database: no boot writes the shipped set's row any + // more, but an earlier one did (the server clone door, stage CD, is + // what reaches a fresh database). + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); const served: any = await read.json(); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts index f0dba0ef8cc..10b54cd131f 100644 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts @@ -45,6 +45,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -122,6 +123,9 @@ describe('[#21861] a data-door edit of a permission set updates its own sys_meta stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); + // [C3 stage 6b-2] The control runs on an EXISTING database: no boot + // writes the shipped set's row any more, but an earlier one did. + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); // A writable runtime package, and a set saved into it at the metadata door. const pkg = await stack.apiAs(token, 'POST', '/packages', { diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 9a94491654c..f057cadfa14 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -57,6 +57,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -154,8 +155,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts index f881b656746..032002a3609 100644 --- a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts @@ -37,6 +37,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PinyinSearchPlugin } from '@objectstack/plugin-pinyin-search'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** `翟璐` normalizes to `zhailu zl` — the term below lives in the companion only. */ const CJK_NAME = '翟璐'; @@ -171,9 +172,8 @@ describe('dogfood: with the pinyin search companion on, a member searches only w const idOf = async (object: string, where: Record) => String((await ql.findOne(object, { where, ...SYS }))?.id ?? ''); const userId = await idOf('sys_user', { email: memberEmail }); - const setId = await idOf('sys_permission_set', { name: memberSet.name }); expect(userId, 'member user seeded').toBeTruthy(); - expect(setId, 'member permission set seeded').toBeTruthy(); + expect(await catalogPermissionSet(ql, memberSet.name), 'member permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: memberSet.name }, SYS); memberRowId = await create(memberToken, ROWS, { name: CJK_NAME }); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e184e421ee0..a3b0a6f97b8 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -64,6 +64,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { leaveOrganization } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -186,8 +187,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // A platform operator outside the organization: `admin_full_access` held // globally, the grant a platform administrator holds under `single`. await stack.signUp(OPERATOR_EMAIL, PASSWORD); - const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); - expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); + expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts index ce98ead44a0..3dd5a007252 100644 --- a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -48,8 +49,8 @@ describe('showcase: ADR-0090 D10 agent intersection (served engine)', () => { // The agent holds the auditor set (viewAllRecords on the private note); the // delegator holds NOTHING beyond the additive member_default baseline. - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); - expect(auditor?.id, 'showcase_auditor seeded').toBeTruthy(); + const auditor = await catalogPermissionSet(ql, 'showcase_auditor'); + expect(auditor, 'showcase_auditor served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: agentId, permission_set: auditor.name }, { context: SYS }); // A private note owned by int-owner (NOT the delegator) — the VAMA probe. diff --git a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts index 94390f593c3..3c7d22063ea 100644 --- a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LIAISON_SET = 'showcase_client_liaison'; @@ -78,8 +79,8 @@ describe('showcase client-liaison fixtures (#9308 fixtures 2 + 4)', () => { ql = await stack.kernel.getServiceAsync('objectql'); liaisonTok = await stack.signUp(LIAISON_EMAIL); - const set = await ql.findOne('sys_permission_set', { where: { name: LIAISON_SET }, context: SYS }); - expect(set?.id, `${LIAISON_SET} is seeded by the security bootstrap`).toBeTruthy(); + const set = await catalogPermissionSet(ql, LIAISON_SET); + expect(set, `${LIAISON_SET} is served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email: LIAISON_EMAIL }, context: SYS }))?.id; await ql.insert('sys_user_permission_set', { user_id: uid, permission_set: set.name }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 01b6bcf89ac..821d7b294d2 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -67,6 +67,7 @@ import { fileURLToPath } from 'node:url'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -343,8 +344,8 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const email = `crudmx-${set.replace(/^showcase_/, '')}@verify.test`; const token = await stack.signUp(email); if (set !== BASELINE_SET) { - const ps = await ql.findOne('sys_permission_set', { where: { name: set }, context: SYS }); - expect(ps?.id, `permission set ${set} seeded by the security bootstrap`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, set); + expect(ps, `permission set ${set} served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; expect(uid, `persona ${email} provisioned`).toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts index c0ca3dec226..b8f474c5640 100644 --- a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -75,8 +76,8 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# const contribId = (await ql.findOne('sys_user', { where: { email: CONTRIB_EMAIL }, context: SYS }))?.id; expect(contribId, 'contributor user provisioned').toBeTruthy(); await ql.insert('sys_user_position', { user_id: contribId, position: 'contributor' }, { context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(contribSet?.id, 'the app’s contributor set is seeded').toBeTruthy(); + const contribSet = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contribSet, 'the app’s contributor set is served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: contribId, permission_set: contribSet.name }, diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index c23377e797c..e0a67c590c4 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -74,6 +74,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked active: true, }); expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); - const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); - expect(ps?.id, 'scratch permission set authored').toBeTruthy(); + const ps = await catalogPermissionSet(ql, SCRATCH_SET); + expect(ps, 'scratch permission set authored').toBeTruthy(); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; expect(memberId, 'member provisioned').toBeTruthy(); await ql.insert( 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 f80e7e1a79f..5c31472454f 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 @@ -30,6 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const SET = 'showcase_contributor'; @@ -96,8 +97,8 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - 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(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the app's ${SET} set is served by the security catalog`).toBeTruthy(); // [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 diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts index 0c88b8c2dbc..53f32344ae8 100644 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -33,6 +33,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('sys_permission_set pure projection (ADR-0094)', () => { let stack: VerifyStack; @@ -45,6 +46,11 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + // [C3 stage 6b-2] The declared-set cases run on an EXISTING database: + // no boot writes these rows any more, but an earlier one did, and the + // data door still guards them. + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); afterAll(async () => { await stack?.stop(); }); diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 251657ca92e..0df4427537c 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -1,12 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// ADR-0086 D5 — stack-declared `permissions` are seeded into -// `sys_permission_set` at boot with package provenance -// (`managed_by:'package'` + `package_id`), closing the ADR-0078 -// inert-metadata violation for this surface: the admin table finally sees a -// package's sets, and uninstall/upgrade have a well-defined owner axis. -// Proven on the real showcase stack, which declares `showcase_contributor` -// and `showcase_member_default` in `src/security/`. +// ADR-0131 D3 (C3 stage 6b-2) — stack-declared `permissions` are served by the +// security catalog from the registry, with their owning package, and the boot +// writes NO `sys_permission_set` row: not for a package's sets, not for the +// platform defaults. (ADR-0086 D5's boot seeding, which this file used to pin, +// retired with the declared-permission seeder.) Proven on the real showcase +// stack, which declares `showcase_contributor` and `showcase_member_default` +// in `src/security/`. // // ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof // for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). @@ -15,8 +15,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; -describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { +describe('showcase: declared permission sets are served by the security catalog (ADR-0131 D3)', () => { let stack: VerifyStack; let ql: any; @@ -27,27 +28,26 @@ describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { }, 60_000); afterAll(async () => { await stack?.stop(); }); - it('declared sets land in sys_permission_set with package provenance', async () => { - const rows = await ql.find('sys_permission_set', { where: {} }, { context: { isSystem: true } }); - const contributor = (rows ?? []).find((r: any) => r.name === 'showcase_contributor'); - expect(contributor, 'declared set must be materialized as a record').toBeTruthy(); - expect(contributor.managed_by).toBe('package'); - expect(contributor.package_id).toBe('com.example.showcase'); - // the record carries the actual declared grants, not an empty husk - const objectPerms = JSON.parse(contributor.object_permissions || '{}'); - expect(Object.keys(objectPerms).length).toBeGreaterThan(0); + it('a declared set is served with its owning package and its declared grants', async () => { + const contributor: any = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contributor, 'declared set served by the security catalog').toBeTruthy(); + expect(contributor._packageId).toBe('com.example.showcase'); + // the definition carries the actual declared grants, not an empty husk + expect(Object.keys(contributor.objects ?? {}).length).toBeGreaterThan(0); }); - it('platform defaults stay env-owned (no package provenance stamped)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'member_default' } }, { context: { isSystem: true } }); - const memberDefault = (rows ?? [])[0]; - expect(memberDefault, 'bootstrapPlatformAdmin default exists').toBeTruthy(); - // bootstrapDeclaredPermissions must not adopt/clobber the insert-once default - expect(memberDefault.managed_by ?? null).not.toBe('package'); + it('a platform default is served too, with no package of the app stamped on it', async () => { + const memberDefault: any = await catalogPermissionSet(ql, 'member_default'); + expect(memberDefault, 'platform default served by the security catalog').toBeTruthy(); + expect(memberDefault._packageId ?? null).not.toBe('com.example.showcase'); }); - it('seeding is idempotent (exactly one row per declared set)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'showcase_contributor' } }, { context: { isSystem: true } }); - expect((rows ?? []).length).toBe(1); + it('the boot writes no sys_permission_set row — and the census can see one', async () => { + const SYS = { context: { isSystem: true } }; + expect(await ql.find('sys_permission_set', { where: {} }, SYS)).toEqual([]); + // Positive control: the same read sees a row once one exists. + await ql.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, SYS); + expect(((await ql.find('sys_permission_set', { where: {} }, SYS)) ?? []).map((r: any) => r.name)).toEqual(['zz_census_control']); + await ql.delete('sys_permission_set', { where: { id: 'ps_census_control' }, ...SYS }); }); }); 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 b7268aebcbf..69a5627a97e 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet, ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -46,8 +47,8 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // Grant the zoo sets directly (system plumbing — the delegated-admin and // anchor gates are exercised through REAL authed requests below). const grant = async (userId: string, setName: string) => { - const ps = await ql.findOne('sys_permission_set', { where: { name: setName }, context: SYS }); - expect(ps?.id, `permission set ${setName} seeded`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, setName); + expect(ps, `permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: ps.name }, @@ -86,10 +87,12 @@ describe('showcase: ADR-0090 permission-model zoo', () => { }); // ── ADR-0090 D12: adminScope lands on the stored set ───────────────────── - it('persists the delegated-admin scope (adminScope → admin_scope JSON)', async () => { - const row = await ql.findOne('sys_permission_set', { where: { name: 'showcase_field_ops_delegate' }, context: SYS }); - expect(row, 'delegate set seeded').toBeTruthy(); - const scope = JSON.parse(row.admin_scope || 'null'); + it('serves the delegated-admin scope on the catalog definition (adminScope)', async () => { + // [C3 stage 6b-2] No row is written for a declared set; the catalog + // definition is what the delegated-admin gate reads. + const def: any = await catalogPermissionSet(ql, 'showcase_field_ops_delegate'); + expect(def, 'delegate set served by the security catalog').toBeTruthy(); + const scope = def.adminScope ?? null; expect(scope?.businessUnit).toBe('Field Operations'); expect(scope?.manageAssignments).toBe(true); expect(scope?.manageBindings).toBe(false); @@ -141,13 +144,17 @@ describe('showcase: ADR-0090 permission-model zoo', () => { 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 }); }; + // [C3 stage 6b-2] The same for a permission-set row: no boot writes one any + // more, while the junction writes below (which grant nothing since stage 1) + // still name a row by id — the shape an existing database keeps. + const existingSetRow = (name: string) => ensureLegacyPermissionSetRow(ql, name, { managedBy: 'package' }); // ── 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 existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); + const ps = await existingSetRow('showcase_contributor'); 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, @@ -180,17 +187,16 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('the delegate cannot hand out a set OFF the allowlist (no self-escalation)', async () => { // showcase_auditor is not in assignablePermissionSets — direct grant refused. - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_permission_set', { user_id: delegateId, - permission_set: ps.name, + permission_set: 'showcase_auditor', }); expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); }); it('the delegate cannot re-compose positions (manageBindings: false)', async () => { const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); + const ps = await existingSetRow('showcase_manager'); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, permission_set_id: ps.id, @@ -202,7 +208,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { 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 auditor = await existingSetRow('showcase_auditor'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: everyone.id, permission_set_id: auditor.id, @@ -213,7 +219,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding the guest-safe set to the guest anchor is accepted', async () => { 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 ps = await existingSetRow('showcase_guest_portal'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: guest.id, permission_set_id: ps.id, @@ -229,8 +235,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // that; this proves the served pipeline actually denies the write now. it('FLS: a contributor can edit a project but NOT its budget fields', async () => { const plain = await ql.findOne('sys_user', { where: { email: 'zoo-plain@verify.test' }, context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: contribSet.name }, { context: SYS }) + await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: 'showcase_contributor' }, { context: SYS }) .catch(() => { /* already granted by an earlier step — idempotent */ }); const projects = await ql.find('showcase_project', { limit: 1, context: SYS }); diff --git a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts index a7178dcdd40..1b680ca3706 100644 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('two-doors permission separation (ADR-0086 P2)', () => { let stack: VerifyStack; @@ -38,45 +39,18 @@ describe('two-doors permission separation (ADR-0086 P2)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); const findSet = async (name: string) => (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - // ── 块1 — package door: draft → publish → materialize ──────────────────── - it('块1: publishing a package permission draft materializes a package-managed row', async () => { - // An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage), - // standing in for the package the studio package door edits. - const PKG = 'com.example.twodoors_ws'; - const NAME = 'twodoors_pkgset'; - - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - mode: 'draft', - packageId: PKG, - item: { - name: NAME, - label: 'Two Doors Set', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - }, - }); - - // Draft only — enforcement/admin-surface must NOT see it yet. - expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy(); - - const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME }); - expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy(); - expect(pub.materializeApplied.success).toBe(true); - - const row = await findSet(NAME); - expect(row, 'published set is now a real record').toBeTruthy(); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe(PKG); - expect(JSON.parse(row.object_permissions || '{}')).toEqual({ - crm_lead: { allowRead: true, allowCreate: true }, - }); - }); + // [C3 stage 6b-2] 块1 — a package-door publish materializing a + // `managed_by:'package'` row — retired with the publish materializer + // (ADR-0131 D3: the boot and the publish write no catalog row). The 块2 + // cases below run on an EXISTING database, where the rows an earlier boot + // seeded still stand and the data door still guards them. // ── 块2 — admin door: artifact-backed rows now REFUSE the overlay (commit ee58392e1) ─ it('块2: an admin edit of a package-managed set is refused — no env overlay is minted (#6483)', async () => { diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index ec482ead452..c109eb5919a 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -52,6 +52,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; @@ -123,8 +124,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); From 6c849cef3938661c2a9a83841dee7ef3bb9f1d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:25:59 +0000 Subject: [PATCH 13/25] test(dogfood): catalogPermissionSet answers any, as the findOne it replaces did (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- packages/qa/dogfood/test/catalog-permission-set.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts index b8a036cc26b..fd48332bbe4 100644 --- a/packages/qa/dogfood/test/catalog-permission-set.ts +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -14,10 +14,11 @@ import { securityCatalogReaderOf } from '@objectstack/core'; -export async function catalogPermissionSet( - ql: unknown, - name: string, -): Promise> | undefined> { +/** + * Typed `any`, as the `ql.findOne` it replaces at every call site was: a fixture + * asserts the definition exists and then reads its fields. + */ +export async function catalogPermissionSet(ql: unknown, name: string): Promise { const reader = securityCatalogReaderOf(ql); if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); return (await reader.resolve('permission', name))?.definition; From 8b9fb7781e40b0d4f87f2fed54b1a0ad1ec318f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:40:13 +0000 Subject: [PATCH 14/25] chore(docs-audit): the CLI command-anchor specimen is os meta register, a command that exists (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- scripts/check-cli-command-ids.mjs | 2 +- scripts/docs-audit/affected-docs.mjs | 32 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/scripts/check-cli-command-ids.mjs b/scripts/check-cli-command-ids.mjs index a7db333dfc6..1a0c4fe1b8f 100644 --- a/scripts/check-cli-command-ids.mjs +++ b/scripts/check-cli-command-ids.mjs @@ -317,7 +317,7 @@ const ROOT_DIR_WATCH_HINTS = ['scripts/**']; const FIXTURE_EXEMPTIONS = [ { file: 'scripts/docs-audit/affected-docs.mjs', - text: 'os meta resync-plan', + text: 'os meta register-plan', why: "affected-docs's own self-test case for 'a sibling command id is not this one'", }, { diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 6010e3c025e..2a549dcc8e3 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -5008,18 +5008,18 @@ function selfTest() { const commandIdCases = [ // [path under the commands root, expected id, label] - ['meta/resync.ts', 'meta resync', 'topic + command — the #9230 specimen'], + ['meta/register.ts', 'meta register', 'topic + command (the #9230 specimen was `meta resync`, since retired)'], ['build.ts', 'build', 'a top-level command'], ['migrate/recorded-by.ts', 'migrate recorded-by', 'a hyphen is an ordinary id char'], ['migrate/index.ts', 'migrate', 'a topic index IS the topic, not "migrate index"'], ['a/b/c.ts', 'a b c', 'deeper nesting is read off the path, not special-cased'], ['cloud/whoami.js', 'cloud whoami', 'the compiled extension resolves the same way'], - ['meta/resync-skip-explanation.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], + ['meta/delete-json-error-code.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], ['__tests__/helper.ts', null, 'an underscore dir is not an oclif id segment'], ['meta/fixture.json', null, 'a non-source file under the commands dir'], ['index.ts', null, 'a bare index at the commands root would name the binary, not a command'], - ['meta/Resync.ts', null, 'an uppercase filename is not an oclif id'], + ['meta/Register.ts', null, 'an uppercase filename is not an oclif id'], ]; for (const [rel, want, label] of commandIdCases) check('commandIdFor', label, rel, want, commandIdFor(rel)); @@ -5043,7 +5043,7 @@ function selfTest() { const readFakePkg = (dir) => (dir === 'packages/cli' ? cliPkg : { name: '@objectstack/spec' }); const anchorFor = (f) => commandAnchorFor(f, readFakePkg, (d) => cliTree.has(d)); const commandAnchorCases = [ - ['packages/cli/src/commands/meta/resync.ts', 'os meta resync', 'the specimen resolves to its phrase'], + ['packages/cli/src/commands/meta/register.ts', 'os meta register', 'the specimen resolves to its phrase'], ['packages/cli/src/commands/build.ts', 'os build', 'a top-level command'], ['packages/cli/src/utils/schema-migrate.js', null, 'a CLI file OUTSIDE the commands dir is not a command'], ['packages/spec/src/commands/thing.ts', null, 'a commands dir in a package that declares no oclif is ignored'], @@ -5057,20 +5057,20 @@ function selfTest() { check('commandAnchorFor', 'a file outside any commands dir is not reported as unmapped either', 'packages/cli/src/utils/schema-migrate.js', null, anchorFor('packages/cli/src/utils/schema-migrate.js')); - // The doc-side matcher. `os meta resync` must find the page that writes it and must NOT + // The doc-side matcher. `os meta register` must find the page that writes it and must NOT // find prose that merely uses the words. - const cmdRe = commandPatternFor('meta resync', ['os', 'objectstack']); + const cmdRe = commandPatternFor('meta register', ['os', 'objectstack']); const commandMatchCases = [ - ['`os migrate`, `os meta resync`, a test run.', true, 'the real drivers.mdx line — inside a code span'], - ['Run `objectstack meta resync --json` to re-sync.', true, 'the alternate declared binary'], - ['npx objectstack meta resync', true, 'the npx form is the same phrase with a prefix'], - ['os meta\tresync', true, 'extra horizontal whitespace between the words'], - ['resync the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], - ['the meta resync step', false, 'the words without a binary name are prose'], - ['macos meta resync', false, 'a binary name must stand alone, not end another word'], - ['os meta resyncAll', false, 'a longer identifier is not the command'], - ['os meta resync-plan', false, 'a sibling command id is not this one'], - ['os meta\nresync', false, 'a line break is not intra-command whitespace'], + ['`os migrate`, `os meta register`, a test run.', true, 'inside a code span'], + ['Run `objectstack meta register --json` to register it.', true, 'the alternate declared binary'], + ['npx objectstack meta register', true, 'the npx form is the same phrase with a prefix'], + ['os meta\tregister', true, 'extra horizontal whitespace between the words'], + ['register the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], + ['the meta register step', false, 'the words without a binary name are prose'], + ['macos meta register', false, 'a binary name must stand alone, not end another word'], + ['os meta registerAll', false, 'a longer identifier is not the command'], + ['os meta register-plan', false, 'a sibling command id is not this one'], + ['os meta\nregister', false, 'a line break is not intra-command whitespace'], ]; for (const [text, want, label] of commandMatchCases) { check('commandPatternFor', label, JSON.stringify(text), want, cmdRe.test(text)); From a574a24cafc51a88ac3bb5ea97a9045c7d0d5131 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:11:10 +0000 Subject: [PATCH 15/25] refactor(plugin-security)!: delete the organization-scope helpers the seeder was the only caller of (#15204 stage 6b-2) warnOrganizationLessRows, catalogIsPerOrganization and listSeedOrganizationIds had one caller, bootstrap-declared-permissions.ts, deleted in this PR. Re-measured: no live caller and no test. Internal (never exported from the package index). Also carries T1's buildExistingByName key-set fence byte-for-byte. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../plugin-security/src/organization-scope.ts | 159 +----------------- .../src/permission-set-projection.ts | 4 +- ...ed-name-lookup-degradation-audible.test.ts | 45 +++++ .../src/seed-write-refusal.test.ts | 2 +- .../plugin-security/src/write-refusals.ts | 4 +- 5 files changed, 57 insertions(+), 157 deletions(-) diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index 7ff44b69017..ce46e06a362 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -2,9 +2,11 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the - * system context one pass runs under, the organizations a boot sweep - * enumerates, the one read that tells an organization's own row apart from - * organization-less residue, and the loud guard that stands in place of a reap. + * system context one pass runs under and the one read that tells an + * organization's own row apart from organization-less residue. [#15204 stage + * 6b-2] The boot sweep's organization enumeration and the loud guard that stood + * in place of a reap retired with the declared-permission seeder, their one + * caller; the history below is kept because the reads that remain still apply it. * * ## Why the catalog is materialized per organization * @@ -66,8 +68,8 @@ * because it never touched a junction table; here the junctions ARE the * grants. * - * So the pass says so instead. {@link warnOrganizationLessRows} names the - * rows and names the remedy, and — this is the load-bearing half — the pass + * So the pass said so instead. Its guard (retired with the seeder) named the + * rows and named the remedy, and — this is the load-bearing half — the pass * still CREATES the organization's own copy. The failure shape it exists to * prevent is the silent no-op: a tenant-threaded pass sees the pre-fix * organization-less row through the driver's compatibility arm, reads the name @@ -78,9 +80,6 @@ * here", and every seeder in this catalog routes through it. */ -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import type { SeedLogger } from './write-refusals.js'; - /** * How many organizations one boot-time seeding sweep enumerates. * @@ -92,8 +91,6 @@ import type { SeedLogger } from './write-refusals.js'; */ export const SEED_ORGANIZATION_SCAN_LIMIT = 500; -const ORGANIZATION_OBJECT = 'sys_organization'; - /** * The system context ONE seeding pass runs under. * @@ -107,52 +104,6 @@ export function seedCtx(organizationId?: string): { isSystem: true; tenantId?: s return organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true }; } -/** Does this posture want per-organization catalog rows? */ -export function catalogIsPerOrganization(posture: TenancyPosture): boolean { - return postureEnforcesWall(posture); -} - -/** - * Enumerate the organizations whose catalog needs seeding. - * - * Returns `null` — not `[]` — when the read FAILS, because the two mean - * opposite things: zero organizations is "nothing to seed", an unreadable - * `sys_organization` is "we do not know". Conflating them turns an outage into - * a silent empty sweep, so a failure is warned and the caller must not proceed - * as though the installation had no tenants. - */ -export async function listSeedOrganizationIds( - ql: any, - logger?: SeedLogger, -): Promise { - let rows: any; - try { - rows = await ql.find(ORGANIZATION_OBJECT, { - fields: ['id'], - limit: SEED_ORGANIZATION_SCAN_LIMIT, - context: seedCtx(), - }); - } catch (e) { - logger?.warn?.( - '[security] could not enumerate organizations — the RBAC catalog was NOT seeded per ' + - 'organization at this call; seeding retries on the next boot and on organization creation', - { object: ORGANIZATION_OBJECT, error: (e as Error)?.message }, - ); - return null; - } - const ids = (Array.isArray(rows) ? rows : []) - .map((r: any) => r?.id) - .filter((id: unknown): id is string => typeof id === 'string' && id !== ''); - if (ids.length >= SEED_ORGANIZATION_SCAN_LIMIT) { - logger?.warn?.( - '[security] organization scan hit its bound — organizations past it are seeded when they are ' + - 'created and on the next boot sweep', - { scanned: ids.length, limit: SEED_ORGANIZATION_SCAN_LIMIT }, - ); - } - return ids; -} - /** The organization a stored row belongs to, `null` for an organization-less one. */ export function rowOrganizationId(row: any): string | null { return (row?.organization_id ?? row?.organizationId) ?? null; @@ -188,99 +139,3 @@ export function resolveOwnOrganizationRow( const residue = list.find((r) => rowOrganizationId(r) === null) ?? null; return { own, organizationLessResidue: residue }; } - -/** - * The machine-readable half of the guard below: WHY this organization-less row - * is visible to a per-organization pass. The two answers take opposite - * remedies, so the classification is a field rather than something a reader has - * to infer from prose (#11532). - */ -export type OrganizationLessRowOrigin = 'platform-bucket' | 'pre-fix-residue'; - -/** - * The loud guard that stands in place of a reap — and the ONE place that tells - * the platform bucket apart from a genuine pre-fix leftover. - * - * Called once per pass with everything the pass found, so an operator gets ONE - * actionable line per class rather than a warning per name. - * - * ## The two classes, and why conflating them was a defect (#11532) - * - * - **`pre-fix-residue`** — a row from before the per-organization conversion. - * Nothing regenerates it, so the ruled remedy holds: re-initialize the - * deployment (correct while it is pre-launch, which is the premise this whole - * repair was ruled on), or adopt each row by hand. - * - * - **`platform-bucket`** — a name `bootstrapPlatformAdmin` still seeds - * organization-less on EVERY boot (`platformBucketNames`). Calling one of - * these "pre-fix" tells an operator they are carrying legacy state they never - * had: on the measured fresh walled rig they were minted 1.3 s before the - * first organization existed, by the very code that then warned about them. - * And the pre-fix remedy does not terminate here — re-initializing recreates - * exactly these rows on the next boot, so the only branch that ends is hand - * adoption, which is also the branch an operator is least likely to pick and - * which hands a platform-wide bucket to one tenant. - * - * Membership is decided by NAME, not by `managed_by`, because the question the - * remedy turns on is "will a re-initialized deployment have this row again?" — - * and for these names it will, whatever provenance the current row carries (a - * pre-ruling install (before commit 712e185db) stores `'admin'` on the very same names). - * - * The pass that emits either warning has ALREADY created the organization's own - * copies — both describe rows beside that catalog, never a refusal to seed. - */ -export function warnOrganizationLessRows( - logger: SeedLogger | undefined, - object: string, - names: string[], - organizationId: string, - platformBucketNames?: Iterable, -): void { - if (names.length === 0) return; - const bucketNames = new Set(platformBucketNames ?? []); - const bucket = names.filter((n) => bucketNames.has(n)); - const residue = names.filter((n) => !bucketNames.has(n)); - - if (bucket.length > 0) { - logger?.warn?.( - `[security] organization-less ${object} rows for the PLATFORM BUCKET are visible to this ` + - `organization's pass. They are not leftovers from an older release: bootstrapPlatformAdmin ` + - `seeds these names without an organization on every boot, including the one that just ran, ` + - `and the ruling of 2026-08-20 keeps it that way (unreaped, reported, outside the ` + - `per-organization conversion) because the platform-admin grant points at the ` + - `admin_full_access row by id. This organization's own copies WERE created, so its catalog ` + - `is complete and no action is required. Re-initializing the deployment does NOT clear ` + - `them — the next boot mints them again. Adopting one by hand (stamping it with an ` + - `organization) does remove it from this list, but hands a platform-wide row to a single ` + - `tenant, so do that only if that is what you mean.`, - { - object, - organization: organizationId, - origin: 'platform-bucket' satisfies OrganizationLessRowOrigin, - names: [...bucket].sort(), - count: bucket.length, - }, - ); - } - - if (residue.length > 0) { - logger?.warn?.( - `[security] pre-fix organization-less ${object} rows are still present for names this ` + - `organization seeds — under a walled posture a row that belongs to no organization is ` + - `invalid state, not a platform-wide default. This organization's own rows WERE created, so ` + - `its catalog is complete; the leftovers below are readable through the driver's ` + - `compatibility arm and belong to nobody. Remedy: re-initialize the deployment, or adopt each ` + - `row by hand by stamping it with the organization that should own it. They are NOT deleted ` + - `automatically — grants (sys_user_position, sys_position_permission_set, ` + - `sys_user_permission_set) point at these row ids, so reaping them would revoke standing ` + - `access with no signal at the moment of loss.`, - { - object, - organization: organizationId, - origin: 'pre-fix-residue' satisfies OrganizationLessRowOrigin, - names: [...residue].sort(), - count: residue.length, - }, - ); - } -} diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 463ca038640..ed1557a8b42 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -134,8 +134,8 @@ export async function tryFind(ql: any, object: string, where: any, limit = 100, * every caller that wants "the rows I could see", wrong for anyone who has to * decide whether a row is GONE. `[]` from that helper means "no row matched OR * the read blew up", and a caller reading it as "gone" reports a deletion that - * an outage invented. `listSeedOrganizationIds` in `per-organization-catalog.ts` - * states the same rule for the same shape: zero organizations is "nothing to + * an outage invented. The retired seeder's organization enumeration + * stated the same rule for the same shape: zero organizations is "nothing to * seed", an unreadable table is "we do not know". * * So `unknown` is its own answer and the caller must decide what it means — diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index 3a7c2f33cf5..1c185efeaf6 100644 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts @@ -199,3 +199,48 @@ describe('[#18570] batched seed existence read — its own failure is audible wi expect(String(seen[0]?.[0])).toContain(BATCHED_READ_FAILED); }); }); + +/** + * [#11451, commit c33f18592] A caller that passes no `equals` predicate emits + * exactly the `where` key set it emitted before the predicate existed: + * `['name']`, not `['name']` plus `undefined`-valued keys that `toEqual` + * would quietly accept. `permission-set-projection.ts`' overlay pass is such + * a caller. The fence used to ride the declared-permission seeder's suite and + * is pinned here, at the function, now that the seeder is retiring. + * + * Both reads are pinned: the batched page and the per-item degradation read + * it falls back to. The predicate-carrying case is the positive control: it + * proves this instrument sees a spread key when one is there. + */ +describe('#11451: a caller that passes no predicate emits its old key set', () => { + it('the batched read emits only `name`', async () => { + const ql = emptyPageQl(); + await buildExistingByName(ql as any, 'sys_permission_set', ['alpha', 'beta']); + expect(ql.calls).toHaveLength(1); + expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name']); + }); + + it('the per-item degradation read emits only `name`', async () => { + const wheres: Record[] = []; + // The batched `$in` read is refused, so the per-item path is taken; each + // per-item read is recorded and answered "no such row". + const ql = { + find: async (_object: string, q?: { where?: Record }) => { + const name = q?.where?.name; + if (name && typeof name === 'object' && '$in' in (name as object)) throw new Error('batched read refused (trigger)'); + wheres.push(q?.where ?? {}); + return [] as any[]; + }, + }; + const index = await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], { warn: () => {} }); + expect((await index.get('alpha')).status).toBe('absent'); + expect(wheres).toHaveLength(1); + expect(Object.keys(wheres[0]!)).toEqual(['name']); + }); + + it('positive control: a caller that passes a predicate emits it', async () => { + const ql = emptyPageQl(); + await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], undefined, undefined, { managed_by: 'platform' }); + expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name', 'managed_by']); + }); +}); 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 47338c864b6..f29e4b80c68 100644 --- a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts @@ -28,7 +28,7 @@ * is the ask, fatal is not: a rethrow would turn a silent degradation into a * boot failure on every deployment carrying the legacy index; * 2. the warning is AGGREGATED — N refusals in one pass produce one line, not - * N, the same discipline `warnOrganizationLessRows` is built on (a catalog + * N, the same discipline the retired organization-less row guard was built on (a catalog * that refuses 400 rows must not print 400 warnings and bury the remedy); * 3. a NON-unique-violation refusal is not silently reclassified as one — it * gets its own line, because the migrate remedy does not apply to it and diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index 090703c15ed..2430dfda0e6 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -135,8 +135,8 @@ export interface SeedWriteRefusalReport { /** * The refusals ONE seeding pass accumulated, so the pass can report them once. * - * Aggregate-then-warn, for the same reason {@link warnOrganizationLessRows} - * aggregates: the catalog seeds every declared position, permission set and + * Aggregate-then-warn, for the same reason the retired organization-less row guard + * aggregated: the catalog seeds every declared position, permission set and * capability for every organization, and the failure this exists to surface * refuses ALL of them. A line per refused row would print hundreds of entries * into the boot log and bury the one sentence naming the remedy. One From fcaedfdaa187e10c35234ad11ef564fb3c916781 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:15:03 +0000 Subject: [PATCH 16/25] test(plugin-security): tighten the stage's new test and comment text (#15204 stage 6b-2) No assertion changes; keeps the residual under the 3,000-line ruling after the organization-scope deletion. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../src/builtin-positions.boot.test.ts | 6 +- ...ederivation-on-permission-mutation.test.ts | 69 +++++-------------- .../plugin-security/src/organization-scope.ts | 6 +- 3 files changed, 19 insertions(+), 62 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 f24a9ed8a92..2576b31d59b 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -347,11 +347,7 @@ async function boot(scenario: Scenario): Promise { } recording = false; - return { - engine, metadata, ledger, - census: await positionCensus(engine), - permissionSetCensus: await permissionSetCensus(engine), - }; + return { engine, metadata, ledger, census: await positionCensus(engine), permissionSetCensus: await permissionSetCensus(engine) }; } /** The four principals' resolved grants — needs the organization and its memberships. */ diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts index 4f0f4999f5a..5b9509d29ff 100644 --- a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -2,21 +2,10 @@ /** * [#22801 → A, C3 stage 6b-2] Every landed `permission` mutation derives the - * `everyone` anchor again. - * - * The derivation used to ride the `permission` publish materializer, which - * retired with the declared-permission seeder (ADR-0131 D3: no catalog row is - * written). It now hangs off the protocol's post-persistence mutation signal - * (`onMetadataMutation`), the one choke point every authoring door funnels - * through — and unconditionally on the body: whether an accepted set still - * qualifies is `readAcceptedEveryoneSets`' question, asked of what the catalog - * now serves, so a publish that drops `isDefault` or deletes the set stops it - * granting at once. - * - * Pinned at the wiring: which events re-derive, which do not, and that a - * bootstrap re-run (the first-user replay) never registers a second listener. - * What a derivation then declares is U2's own suite - * (`suggested-audience-bindings.test.ts`, the dogfood acceptance pin). + * `everyone` anchor again, off `onMetadataMutation` (the publish materializer + * it rode retired with the seeder, ADR-0131 D3). Pinned at the wiring: which + * events re-derive, and that a bootstrap re-run never adds a second listener. + * What a derivation declares is U2's own suite. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -27,23 +16,15 @@ afterEach(() => { vi.restoreAllMocks(); }); type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; -/** - * Field equality plus `{ $in: [...] }`. Anything else is REFUSED rather than - * answered — a combinator read as a field name matches no row and would report - * that as "no rows". - */ +/** Field equality plus `$in`; anything else is REFUSED, never read as "no rows". */ function rowMatches(row: any, where: Record = {}): boolean { return Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) { - throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } + if (k.startsWith('$')) throw new Error(`this double cannot answer '${k}'`); const actual = row?.[k] ?? null; if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ops = Object.keys(v as Record); - if (ops.length === 1 && ops[0] === '$in') { - return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); - } - throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); + if (ops.length === 1 && ops[0] === '$in') return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + throw new Error(`this double cannot answer ${JSON.stringify(ops)}`); } return actual === (v ?? null); }); @@ -55,39 +36,27 @@ async function boot() { const ql: any = { registerMiddleware: () => {}, // Only the identity objects the plugin's boot gate requires. - getSchema: (name: string) => - name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, + getSchema: (name: string) => (name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined), registry: { listItems: () => [] }, find: async (object: string, opts?: any) => { const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); - // The caller's bound, applied BY PRESENCE and after the filter - // (`check:objectql-double-limit`). + // The caller's bound, by presence, after the filter (`check:objectql-double-limit`). return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); }, - findOne: async (object: string, query?: EngineFindOneQueryInput) => { - assertEngineFindOnePredicate(object, query); - return null; - }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, count: async () => 0, insert: async (object: string, data: any) => { const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; (tables[object] ??= []).push(row); return { ...row }; }, - update: async (_object: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - return 0; - }, + update: async (_object: string, data: any, options?: any) => { assertEngineUpdateDispatch(data, options); return 0; }, }; const listeners: MutationListener[] = []; const protocol = { - // Present so the projection wiring takes its awaited seam and adds no - // listener of its own: every listener counted below is this stage's. + // Present so the projection wiring adds no listener of its own. registerMutationProjector: vi.fn(), - onMetadataMutation: (listener: MutationListener) => { - listeners.push(listener); - return () => {}; - }, + onMetadataMutation: (listener: MutationListener) => { listeners.push(listener); return () => {}; }, }; const services: Record = { manifest: { register: vi.fn() }, @@ -99,10 +68,7 @@ async function boot() { const ctx: any = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, + getService: (name: string) => { if (!(name in services)) throw new Error(`no service: ${name}`); return services[name]; }, hook, }; const derive = vi.spyOn(SecurityPlugin.prototype as any, 'declareEveryone').mockResolvedValue(undefined); @@ -112,7 +78,6 @@ async function boot() { const fire = async (event: string) => { for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb(); }; - /** Let a fire-and-forget listener's promise chain settle. */ const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; return { ctx, fire, settle, listeners, derive }; } @@ -122,9 +87,7 @@ describe('[#22801 → A] a landed permission mutation re-derives everyone', () = const { ctx, fire, listeners } = await boot(); await fire('kernel:ready'); // Positive control: the bootstrap really completed. - expect( - ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), - ).toHaveLength(1); + expect(ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete'))).toHaveLength(1); expect(listeners).toHaveLength(1); await fire('kernel:ready'); expect(listeners).toHaveLength(1); diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index ce46e06a362..9dc7148da58 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -3,10 +3,8 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the * system context one pass runs under and the one read that tells an - * organization's own row apart from organization-less residue. [#15204 stage - * 6b-2] The boot sweep's organization enumeration and the loud guard that stood - * in place of a reap retired with the declared-permission seeder, their one - * caller; the history below is kept because the reads that remain still apply it. + * organization's own row apart from organization-less residue (the sweep and + * the reap guard retired with their one caller, the seeder; #15204 stage 6b-2). * * ## Why the catalog is materialized per organization * From d15a15671e4b8a2e0c147df39192ba684951cf02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:09:36 +0000 Subject: [PATCH 17/25] chore(census): regenerate the tenant-audit census after merging main Main moved the population to 227; this branch's removal of the boot's sys_permission_set writes (bootstrap-platform-admin update, normalize-managed-by update) takes it to 225. Regenerated with `node scripts/tenant-audit-census.mjs --write`; the hand-written prose figures follow the regenerated tables. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 16 ++++----- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f14126367c8..2893a867698 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 44 of them — just under a fifth +typed `any` have no type to read, and there are 42 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 227 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 225 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 | **227** | +| 175 write call sites | quoted in the merged changeset | **225** | | 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 | **155 of 227** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 225** decidable, **70** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 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 44 sites reached +one, and both are counted in the generated tables below: the 42 sites reached through an erased (`any`) receiver, and the 52 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 -122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a +120 of 225 (53%) as decidably elevated, with 97 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 / 227`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 225`, 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,17 +223,17 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **227** | +| write call sites on the application surface | **225** | | …whose object name is statically decidable | 155 | -| …whose object name is chosen at run time | 72 | +| …whose object name is chosen at run time | 70 | | …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 166 | +| threading a tenant context | 164 | | 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 | 122 | +| threading a decidably ELEVATED (`isSystem`) context | 120 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | @@ -241,12 +241,12 @@ cannot read, and they are neither in nor out. | :--- | ---: | | receiver carried a readable engine type | 183 | | receiver erased, placed by the object NAME | 30 | -| receiver erased, placed by an `object: string` PARAMETER | 9 | +| receiver erased, placed by an `object: string` PARAMETER | 7 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 11 | +| object name is an `object: string` parameter | 9 | | object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `5adf11374`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 161 | 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 9ca4d164a81..ae14aaa7bef 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,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 227 | +| Write call sites | 225 | | Object name statically decidable | 155 | -| Object name chosen at run time | 72 | +| Object name chosen at run time | 70 | | Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 166 | +| Threading a tenant context | 164 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 122 | +| Threading a decidably elevated context | 120 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `5adf11374`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 161 | @@ -156,13 +156,11 @@ Measured on 2026-10-11 at `be55432f6`. | `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-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 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | From 2a8828587c0ef636046a5ff8fb89b5569b807e7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:55:58 +0000 Subject: [PATCH 18/25] fix(security): an uninstall revokes the grants naming the package's permission sets With no sys_permission_set row standing for a package's set, the `security.package-permissions` cleanup found no grant to revoke, and a grant naming the set survived the uninstall, live again on reinstall (ADR-0090 D5, "no ghost grants"; maintainer ruling: option 1, B). Both uninstall doors now capture the set names the package held just before they withdraw it from the registry, through one helper, `captureUninstallDeclared`, and hand them to the cleanups as the optional `declared` argument. The withdrawal still precedes the cleanups. Two sources, unioned: the registry's `securityCatalogNamesHeldBy` (an installed package's claims and item slots, disabled packages included) and, on `deletePackage`, the package's stored `permission` rows, since a base-authored item sits in the registry's bare slot with no `_packageId`. The cleanup revokes every `sys_user_permission_set` grant naming a captured set; the legacy row path stays for an already-seeded database. Pins: the registry read; the capture, the runner hand-off and `deletePackage`'s order; the install-local door's order and hand-off; the cleanup's revoke with its no-names control; end to end on each door (the install-local CLI suite, rewritten to the catalog, with a reinstall case; a deletePackage dogfood suite). Dropping the capture turns the door pins red. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../15204-s6b2-declared-permission-seeders.md | 10 ++ ...cal-uninstall-cleanups.integration.test.ts | 89 ++++++++++-------- .../src/marketplace-install-local-plugin.ts | 22 +++-- ...install-local-uninstall-withdrawal.test.ts | 38 +++++++- packages/metadata-protocol/src/index.ts | 3 +- packages/metadata-protocol/src/protocol.ts | 70 +++++++++++++- ...rotocol.uninstall-declared-capture.test.ts | 91 +++++++++++++++++++ ...try-security-catalog-names-held-by.test.ts | 44 +++++++++ packages/objectql/src/registry.ts | 23 +++++ .../src/cleanup-package-permissions.test.ts | 30 ++++++ .../src/cleanup-package-permissions.ts | 16 ++++ .../plugin-security/src/security-plugin.ts | 10 +- ...stall-revokes-named-grants.dogfood.test.ts | 88 ++++++++++++++++++ 13 files changed, 480 insertions(+), 54 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts create mode 100644 packages/objectql/src/registry-security-catalog-names-held-by.test.ts create mode 100644 packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index 97b7b7fbb06..e2776c1c69d 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -1,5 +1,8 @@ --- '@objectstack/plugin-security': minor +'@objectstack/metadata-protocol': minor +'@objectstack/objectql': minor +'@objectstack/cloud-connection': patch --- feat(plugin-security)!: the boot writes no `sys_permission_set` row — the declared-permission seeder and the platform-default materialization are deleted @@ -14,8 +17,15 @@ Clause-②: yes (narrowing) - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. +- **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. +Added (additive, nothing an author writes changes): + +- `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. +- `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. +- `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: - `bootstrapDeclaredPermissions` diff --git a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts index ed75effa5e1..8c7c8a56d29 100644 --- a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts +++ b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts @@ -22,6 +22,17 @@ * `security.package-permissions` is the one that removes package-owned sets with * their position and user bindings. This door never ran that registry. * + * ## Since ADR-0131 C3 stage 6b-2 + * + * No door writes a `sys_permission_set` row any more: the package's set is + * served by the security catalog from the registry (`GET /meta/permission/…`), + * and the grant names it. So the cleanup can no longer find the grants through + * a row. The door captures the set names the package held just before it + * withdraws the package, and the cleanup revokes the grants naming them + * (ADR-0090 D5, maintainer ruling on #15204: option 1). A grant naming the + * set would otherwise survive the uninstall, inert, and be live again the + * moment the package is reinstalled; the reinstall order pins that it is not. + * * ## What each `it` reads * * One fixture, two orders of events, each probed through the data route a user @@ -241,17 +252,21 @@ function rowsOf(answer: Answer): any[] { const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; -/** What the data route answers about the package's grants at one moment. */ +/** What the routes answer about the package's set and its grants at one moment. */ interface Grants { - /** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */ + /** `GET /meta/permission/…` — the set as the security catalog serves it (ADR-0131 D3). */ sets: Answer; /** `GET /data/sys_user_permission_set?permission_set=…` — the user grant made before the uninstall, by the set's name (ADR-0131 D4). */ bindings: Answer; } -async function readGrants(live: LiveStart, token: string, _setId: string): Promise { +/** The name the catalog serves for {@link PERMISSION_SET}, or `undefined` when it serves none. */ +const servedName = (a: Answer): string | undefined => + a.status === 200 ? (a.body?.item ?? a.body?.data?.item)?.name : undefined; + +async function readGrants(live: LiveStart, token: string): Promise { return { - sets: await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token), + sets: await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, token), bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set=${PERMISSION_SET}`, token), }; } @@ -263,7 +278,6 @@ interface Run { before?: Grants; /** `POST /data/sys_user_permission_set` — the grant whose survival is the defect's second half. */ granted?: Answer; - setId?: string; uninstall?: Answer; /** Same process, right after the DELETE answered. */ after?: Grants; @@ -281,8 +295,8 @@ interface Run { afterOtherInstall?: Grants; /** Reinstall order only: the same package installed again, in the same process, after its DELETE. */ reinstall?: { exit: number | null; output: string }; - /** Reinstall order only: the object and the set right after that reinstall. */ - afterReinstall?: { object: Answer; sets: Answer }; + /** Reinstall order only: the object, the set and the grant right after that reinstall. */ + afterReinstall?: { object: Answer; sets: Answer; bindings: Answer }; } /** @@ -291,26 +305,24 @@ interface Run { * the same act on both orders of events. */ async function grantThenUninstall(live: LiveStart, session: Session, run: Run): Promise { - const sets = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, session.token); - const setId = rowsOf(sets)[0]?.id; - if (typeof setId !== 'string') { - throw new Error(`precondition: no ${PERMISSION_SET} row to grant — ${sets.status} ${JSON.stringify(sets.body)}\n--- output ---\n${live.output().slice(-3000)}`); + const sets = await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, session.token); + if (servedName(sets) !== PERMISSION_SET) { + throw new Error(`precondition: the catalog serves no ${PERMISSION_SET} to grant — ${sets.status} ${JSON.stringify(sets.body)}\n--- output ---\n${live.output().slice(-3000)}`); } - run.setId = setId; run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, { user_id: session.userId, permission_set: PERMISSION_SET, }); - run.before = await readGrants(live, session.token, setId); + run.before = await readGrants(live, session.token); run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); run.uninstall = await http(live, 'DELETE', UNINSTALL, session.token); - run.after = await readGrants(live, session.token, setId); + run.after = await readGrants(live, session.token); run.objectAfter = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); } async function readAfterRestart(live: LiveStart, run: Run): Promise { const { token } = await authenticate(live); - run.restarted = await readGrants(live, token, run.setId!); + run.restarted = await readGrants(live, token); run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token); } @@ -369,7 +381,7 @@ beforeAll(async () => { runs.reseed.install = await packageInstall(appDir, f); await grantThenUninstall(f, fSession, runs.reseed); runs.reseed.otherInstall = await packageInstall(otherAppDir, f); - runs.reseed.afterOtherInstall = await readGrants(f, fSession.token, runs.reseed.setId!); + runs.reseed.afterOtherInstall = await readGrants(f, fSession.token); await stopGroup(f.child); const g = await bootStart(reseedDir, reseedHome, port); await readAfterRestart(g, runs.reseed); @@ -387,7 +399,7 @@ beforeAll(async () => { runs.reinstall.reinstall = await packageInstall(appDir, h); runs.reinstall.afterReinstall = { object: await http(h, 'GET', `/api/v1/data/${TASK}`, hSession.token), - sets: await http(h, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, hSession.token), + ...await readGrants(h, hSession.token), }; await stopGroup(h.child); }, 9 * BOOT_TIMEOUT_MS); @@ -404,8 +416,7 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean const run = runs[name]; expect(run.install?.exit, run.install?.output).toBe(0); expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201); - expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id])) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.before!.sets), JSON.stringify(run.before!.sets.body)).toBe(PERMISSION_SET); expect(rowsOf(run.before!.bindings).map((r) => r?.id)).toEqual([recordOf(run.granted!)?.id]); }); @@ -417,10 +428,9 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(security, JSON.stringify(run.uninstall?.body)).toMatchObject({ success: true }); }); - it('right after the DELETE: no package-managed set, no grant of it', () => { + it('right after the DELETE: the catalog serves no such set, and no grant of it remains', () => { const run = runs[name]; - expect(run.after!.sets.status).toBe(200); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); expect(run.after!.bindings.status).toBe(200); expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); @@ -438,8 +448,7 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean it('after a restart: still no set and no grant, and the package object is gone', () => { const run = runs[name]; expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); - expect(run.restarted!.sets.status).toBe(200); - expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); + expect(servedName(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toBeUndefined(); expect(run.restarted!.bindings.status).toBe(200); expect(rowsOf(run.restarted!.bindings), JSON.stringify(run.restarted!.bindings.body)).toEqual([]); }); @@ -465,16 +474,15 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.install?.exit, run.install?.output).toBe(0); expect(run.otherInstall?.exit, run.otherInstall?.output).toBe(0); expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201); - expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id])) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.before!.sets), JSON.stringify(run.before!.sets.body)).toBe(PERMISSION_SET); expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); it('the grant stays revoked through the other install and the restart, and the package object is gone', () => { const run = runs.reseed; - for (const answer of [run.afterOtherInstall!.sets, run.afterOtherInstall!.bindings, run.restarted!.sets, run.restarted!.bindings]) { + for (const answer of [run.afterOtherInstall!.bindings, run.restarted!.bindings]) { expect(answer.status, JSON.stringify(answer.body)).toBe(200); } expect(rowsOf(run.afterOtherInstall!.bindings), JSON.stringify(run.afterOtherInstall!.bindings.body)).toEqual([]); @@ -482,14 +490,14 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); }); - it('the other package\'s hot install does not re-project the uninstalled package\'s set', () => { + it('the other package\'s hot install does not bring the uninstalled package\'s set back', () => { const run = runs.reseed; - expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]); + expect(servedName(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toBeUndefined(); }); - it('after the restart there is no package-managed set for the uninstalled package — no orphan row', () => { + it('after the restart the catalog still serves no set for the uninstalled package', () => { const run = runs.reseed; - expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); + expect(servedName(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toBeUndefined(); }); }); @@ -506,16 +514,23 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200); expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404); - expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + expect(servedName(run.after!.sets), JSON.stringify(run.after!.sets.body)).toBeUndefined(); + expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); - it('the reinstall registers the package again: its object answers, and its set is projected once', () => { + it('the reinstall registers the package again: its object answers, and the catalog serves its set again', () => { const run = runs.reinstall; expect(run.reinstall?.exit, run.reinstall?.output).toBe(0); expect(run.afterReinstall!.object.status, JSON.stringify(run.afterReinstall!.object.body)).toBe(200); - expect(run.afterReinstall!.sets.status).toBe(200); - expect(rowsOf(run.afterReinstall!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]), JSON.stringify(run.afterReinstall!.sets.body)) - .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + expect(servedName(run.afterReinstall!.sets), JSON.stringify(run.afterReinstall!.sets.body)).toBe(PERMISSION_SET); + }); + + // [ADR-0090 D5] The grant the uninstall revoked is not live again: the set + // is back under the same name, and nothing names it. + it('the grant revoked by the DELETE does not come back with the set', () => { + const run = runs.reinstall; + expect(run.afterReinstall!.bindings.status, JSON.stringify(run.afterReinstall!.bindings.body)).toBe(200); + expect(rowsOf(run.afterReinstall!.bindings), JSON.stringify(run.afterReinstall!.bindings.body)).toEqual([]); }); }); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index c87f409f713..306700560c7 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -148,7 +148,8 @@ import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import type { DeletePackageRequest, UninstallCleanupOutcome } from '@objectstack/metadata-protocol'; +import type { DeletePackageRequest, UninstallCleanupOutcome, UninstallDeclared } from '@objectstack/metadata-protocol'; +import { captureUninstallDeclared } from '@objectstack/metadata-protocol'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -171,7 +172,7 @@ const ROUTE_BASE = '/api/v1/marketplace/install-local'; */ type UninstallCleanupRunner = { runUninstallCleanups?( - request: Pick, + request: Pick & { declared?: UninstallDeclared }, ): Promise; }; @@ -1706,13 +1707,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // `metadata:reloaded` in that window would re-project the very sets // they had just selected and removed. The cleanups lose nothing by // going second: `deletePackage` already runs every registered cleanup - // after its withdrawal, and the one registered today - // (`security.package-permissions`) selects by package id in the store, - // never through the registry. + // after its withdrawal, and `security.package-permissions` reads no + // registry: it selects by package id in the store, and by the names + // handed to it. [ADR-0090 D5] Those names are captured just before the + // withdrawal releases them — synchronously, so still no `await` before + // it, at the same point and through the same helper as + // `deletePackage` — and the cleanup revokes the grants naming them. + let registry: unknown; + try { registry = ctx.getService('objectql')?.registry; } catch { /* no data engine — nothing registered */ } + const declared = captureUninstallDeclared(registry, manifestId); const withdrawal = this.withdrawFromRunningKernel(ctx, manifestId); const cleanups = [ ...(withdrawal ? [withdrawal] : []), - ...await this.runUninstallCleanups(ctx, manifestId, admission.userId), + ...await this.runUninstallCleanups(ctx, manifestId, admission.userId, declared), ]; ctx.logger?.info?.( `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` @@ -1839,6 +1846,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx: PluginContext, manifestId: string, actor: string, + declared?: UninstallDeclared, ): Promise => { let protocol: UninstallCleanupRunner | undefined; try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } @@ -1855,7 +1863,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ); } else { try { - outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor }); + outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor, ...(declared ? { declared } : {}) }); } catch (err: any) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] the uninstall cleanups of ${manifestId} could not be run: ${err?.message ?? err}`); outcomes = notRun('the uninstall cleanups could not be run'); diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts index dbab1c1dd09..4c04b288b8a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts @@ -26,7 +26,9 @@ * - a refused withdrawal is reported on the response as a failed outcome, * never swallowed, the cleanups still run, and the cause goes to the * operator log only; - * - a registry that does not hold the package has nothing to withdraw. + * - a registry that does not hold the package has nothing to withdraw; + * - [ADR-0090 D5] the permission-set names the package held are captured + * just BEFORE the withdrawal releases them, and handed to the cleanups. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -103,12 +105,20 @@ async function bootPlugin(opts: { registered?: string[]; refuseWithdrawal?: string; auth?: unknown; + held?: string[]; } = {}) { const events: Array<{ step: string; id: string; ledgerOnDisk: boolean }> = []; + const handed: unknown[] = []; const packages = new Map((opts.registered ?? [APP_ID, USER_CODE_ID]).map((id) => [id, { manifest: { id } }])); const registry = { getAllPackages: () => [...packages.values()], getPackage: (id: string) => packages.get(id), + ...(opts.held ? { + securityCatalogNamesHeldBy: (id: string) => { + events.push({ step: 'capture', id, ledgerOnDisk: existsSync(ledgerFile()) }); + return packages.has(id) ? opts.held : []; + }, + } : {}), uninstallPackage: vi.fn((id: string) => { events.push({ step: 'withdraw', id, ledgerOnDisk: existsSync(ledgerFile()) }); if (opts.refuseWithdrawal) throw new Error(opts.refuseWithdrawal); @@ -123,8 +133,9 @@ async function bootPlugin(opts: { auth: opts.auth ?? installerAuthService(), objectql: withInstallerGrants({ syncSchemas: async () => undefined, registry }), protocol: { - runUninstallCleanups: vi.fn(async (request: { packageId: string }) => { + runUninstallCleanups: vi.fn(async (request: { packageId: string; declared?: unknown }) => { events.push({ step: 'cleanups', id: request.packageId, ledgerOnDisk: existsSync(ledgerFile()) }); + handed.push(request.declared); return CLEANUP_OUTCOMES; }), }, @@ -145,7 +156,7 @@ async function bootPlugin(opts: { const uninstall = async (manifestId = APP_ID) => rawApp.routes.get('DELETE /api/v1/marketplace/install-local/:manifestId')!(makeDeleteC(manifestId)); const warnings = () => logger.warn.mock.calls.map((c: any[]) => String(c[0])); - return { uninstall, events, packages, registry, warnings }; + return { uninstall, events, packages, registry, warnings, handed }; } describe('#21576: an install-local uninstall withdraws the package from the running kernel', () => { @@ -169,6 +180,27 @@ describe('#21576: an install-local uninstall withdraws the package from the runn expect(warnings()).toEqual([]); }); + it('[ADR-0090 D5] captures the package\'s permission-set names BEFORE the withdrawal, and hands them to the cleanups', async () => { + seedLedger(); + const { uninstall, events, handed } = await bootPlugin({ held: ['tasks_app_task_user'] }); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(events.map((e) => e.step)).toEqual(['capture', 'withdraw', 'cleanups']); + expect(handed).toEqual([{ permission: ['tasks_app_task_user'] }]); + }); + + it('control — a registry that cannot say what the package held hands the cleanups nothing', async () => { + seedLedger(); + const { uninstall, events, handed } = await bootPlugin(); + + await uninstall(); + + expect(events.map((e) => e.step)).toEqual(['withdraw', 'cleanups']); + expect(handed).toEqual([undefined]); + }); + it('a refused caller withdraws nothing', async () => { seedLedger(); const { uninstall, registry, packages } = await bootPlugin({ diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index e2a289d4a31..e0574a48143 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -118,7 +118,8 @@ export type { SeedTenancySplit, SeedTenancyCollision, } from './migrations/seed-tenancy-backfill.js'; -export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; +export type { UninstallCleanup, UninstallCleanupOutcome, UninstallDeclared } from './protocol.js'; +export { captureUninstallDeclared } from './protocol.js'; // [#9960] The ONE declared shape of the `deletePackage` seam, exported so the // two consumers that speak it (`@objectstack/rest`'s direct-mount package // registrar and the `@objectstack/runtime` dispatcher twin) type the seam diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2a28fdcd099..1dd80f31160 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5149,8 +5149,68 @@ export type PublishMaterializer = (args: { export type UninstallCleanup = (args: { packageId: string; actor?: string; + /** + * What the package declared, read from the registry just before the door + * withdrew it ({@link captureUninstallDeclared}). Optional: absent when the + * registry did not hold the package or cannot answer, and a cleanup that + * does not read it behaves as before. + */ + declared?: UninstallDeclared; }) => Promise<{ success: boolean; removed: number; error?: string }>; +/** + * [ADR-0090 D5, ADR-0131 D4] The security-catalog names an uninstalled package + * held. A grant names its set, and the package's sets are served from the + * registry, not stored, so after the withdrawal nothing else knows them. + */ +export interface UninstallDeclared { + /** The permission-set names the package held. */ + permission?: string[]; +} + +/** + * Capture what `packageId` declared, for {@link UninstallCleanup}'s `declared` + * — the ONE capture both uninstall doors (`deletePackage` and install-local's + * `DELETE`) call, at the same point: after the uninstall is decided and just + * BEFORE `registry.uninstallPackage`, which releases the names. The withdrawal + * still precedes the cleanups (#21576); only what they are handed grows. + * + * Two sources, unioned: the names the registry says the package holds (an + * installed package's claims and item slots), and the `permission` rows of the + * package's `sys_metadata` the door already read (`stored`). The second is + * what names a writable base's sets (ADR-0070): an item authored into a base + * is stored, and the registry holds it in the bare slot with no `_packageId` + * (provenance `org`, measured), so the registry alone cannot attribute it to + * the base. + * + * `undefined` when neither source names a set — so a package that declared + * none hands its cleanups exactly the request they had before. + */ +export function captureUninstallDeclared( + registry: unknown, + packageId: string, + stored?: ReadonlyArray<{ type?: unknown; name?: unknown } | null | undefined>, +): UninstallDeclared | undefined { + const names = new Set(); + const held = (registry as { securityCatalogNamesHeldBy?: (id: string, type: 'permission') => unknown } | null | undefined) + ?.securityCatalogNamesHeldBy; + if (typeof held === 'function') { + try { + const fromRegistry = held.call(registry, packageId, 'permission'); + if (Array.isArray(fromRegistry)) { + for (const n of fromRegistry) if (typeof n === 'string' && n !== '') names.add(n); + } + } catch { /* the registry could not say; the stored rows still can */ } + } + if (stored) { + for (const row of stored) { + const type = row?.type; + if ((type === 'permission' || type === 'permissions') && typeof row?.name === 'string' && row.name !== '') names.add(row.name); + } + } + return names.size > 0 ? { permission: [...names] } : undefined; +} + /** Per-cleanup outcome reported on the `deletePackage` response. */ export interface UninstallCleanupOutcome { name: string; @@ -5950,7 +6010,7 @@ export class ObjectStackProtocolImplementation implements * does — and never swallows it. */ async runUninstallCleanups( - request: Pick, + request: Pick & { declared?: UninstallDeclared }, ): Promise { const cleanups: UninstallCleanupOutcome[] = []; for (const [name, cleanup] of this.uninstallCleanups) { @@ -5958,6 +6018,7 @@ export class ObjectStackProtocolImplementation implements const r = await cleanup({ packageId: request.packageId, ...(request.actor ? { actor: request.actor } : {}), + ...(request.declared ? { declared: request.declared } : {}), }); cleanups.push({ name, @@ -23990,6 +24051,11 @@ export class ObjectStackProtocolImplementation implements // registry that lacks that method, or a throw nothing asked ahead of // time: it is logged, not fatal, because the durable row is already // gone and the next boot is clean either way. + // + // [ADR-0090 D5] What the package declared is captured first: the + // withdrawal releases its names, and the cleanups below revoke the + // grants that name them. + const declared = captureUninstallDeclared((this.engine as any)?.registry, request.packageId, rows); try { (this.engine as any)?.registry?.uninstallPackage?.(request.packageId); } catch (e) { @@ -24006,7 +24072,7 @@ export class ObjectStackProtocolImplementation implements // a security condition) is visible to the caller, never silent. // [#21490] Through the registry's one runner, which install-local's // uninstall door calls too. - const cleanups = await this.runUninstallCleanups(request); + const cleanups = await this.runUninstallCleanups({ ...request, ...(declared ? { declared } : {}) }); return { success: failed.length === 0 && deleted.length > 0, diff --git a/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts b/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts new file mode 100644 index 00000000000..7e503e1c695 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.uninstall-declared-capture.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D5, ADR-0131 D4] The uninstall cleanups are handed what the + * package declared, captured from the registry just BEFORE the withdrawal + * releases it. + * + * A grant names its permission set, and a package's sets are served from the + * registry, not stored as rows. The withdrawal (`registry.uninstallPackage`) + * runs before the cleanups (#21576) and releases the names, so a cleanup that + * looked them up itself would find nothing, and the grants naming them would + * survive the uninstall. {@link captureUninstallDeclared} is the one capture + * both doors call; these cases pin it, the runner's hand-off, and + * `deletePackage`'s order. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation, captureUninstallDeclared } from './index.js'; + +const PACKAGE_ID = 'com.example.tasksapp'; + +describe('captureUninstallDeclared — the one capture both uninstall doors call', () => { + it('reads the permission-set names the registry says the package holds', () => { + const held = vi.fn(() => ['tasks_app_task_user', 'tasks_app_admin']); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: held }, PACKAGE_ID)) + .toEqual({ permission: ['tasks_app_task_user', 'tasks_app_admin'] }); + expect(held.mock.calls).toEqual([[PACKAGE_ID, 'permission']]); + }); + + it('answers nothing when no source names a set: absent, a build without the read, a throw, or none declared', () => { + expect(captureUninstallDeclared(undefined, PACKAGE_ID)).toBeUndefined(); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => [] }, PACKAGE_ID, [{ type: 'object', name: 'x' }])).toBeUndefined(); + expect(captureUninstallDeclared({ uninstallPackage: () => true }, PACKAGE_ID)).toBeUndefined(); + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => { throw new Error('boom'); } }, PACKAGE_ID)).toBeUndefined(); + }); + + it('unions the package\'s stored `permission` rows: a writable base\'s sets are stored, not held by the registry', () => { + const stored = [ + { type: 'permission', name: 'base_set' }, + { type: 'object', name: 'base_widget' }, + { type: 'permission', name: 'tasks_app_task_user' }, + ]; + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => ['tasks_app_task_user'] }, PACKAGE_ID, stored)) + .toEqual({ permission: ['tasks_app_task_user', 'base_set'] }); + expect(captureUninstallDeclared(undefined, PACKAGE_ID, stored)).toEqual({ permission: ['base_set', 'tasks_app_task_user'] }); + }); + + it('keeps only non-empty string names', () => { + expect(captureUninstallDeclared({ securityCatalogNamesHeldBy: () => ['a', '', 7, null, 'b'] }, PACKAGE_ID)) + .toEqual({ permission: ['a', 'b'] }); + }); +}); + +describe('runUninstallCleanups hands `declared` to every cleanup, and only when it has one', () => { + function makeProtocol(registry: Record) { + const engine = { registry, find: async () => [] }; + const services = new Map([['package', { delete: async () => ({ success: true }) }]]); + return new ObjectStackProtocolImplementation(engine as never, () => services as never); + } + + it('passes the captured names through, beside the package id and actor', async () => { + const protocol = makeProtocol({}); + const cleanup = vi.fn(async () => ({ success: true, removed: 1 })); + protocol.registerUninstallCleanup('security.package-permissions', cleanup); + + await protocol.runUninstallCleanups({ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }); + + expect(cleanup.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }]]); + }); + + it('deletePackage captures BEFORE it withdraws, and the cleanups get what it captured', async () => { + const order: string[] = []; + const registry = { + assertPackageUninstallable: () => undefined, + securityCatalogNamesHeldBy: vi.fn((id: string) => { + order.push(`capture ${id}`); + // The withdrawal releases the names: after it, the registry holds none. + return order.includes(`withdraw ${id}`) ? [] : ['tasks_app_task_user']; + }), + uninstallPackage: vi.fn((id: string) => { order.push(`withdraw ${id}`); return true; }), + }; + const protocol = makeProtocol(registry); + const cleanup = vi.fn(async () => { order.push('cleanup'); return { success: true, removed: 1 }; }); + protocol.registerUninstallCleanup('security.package-permissions', cleanup); + + await protocol.deletePackage({ packageId: PACKAGE_ID, actor: 'usr_1' }); + + expect(order).toEqual([`capture ${PACKAGE_ID}`, `withdraw ${PACKAGE_ID}`, 'cleanup']); + expect(cleanup.mock.calls).toEqual([[{ packageId: PACKAGE_ID, actor: 'usr_1', declared: { permission: ['tasks_app_task_user'] } }]]); + }); +}); diff --git a/packages/objectql/src/registry-security-catalog-names-held-by.test.ts b/packages/objectql/src/registry-security-catalog-names-held-by.test.ts new file mode 100644 index 00000000000..0706dbcb409 --- /dev/null +++ b/packages/objectql/src/registry-security-catalog-names-held-by.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D5, ADR-0131 D4] `securityCatalogNamesHeldBy` — the read the + * uninstall doors take just before `uninstallPackage` releases a package's + * names, so the cleanups can revoke the grants naming its permission sets. + * It answers for a disabled package (still installed, still holding its + * names) and nothing once the package is gone. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry.js'; + +const manifest = (id: string, permissions: string[]) => + ({ id, name: `Pkg ${id}`, version: '1.0.0', permissions: permissions.map((name) => ({ name, label: name })) }) as any; + +describe('SchemaRegistry.securityCatalogNamesHeldBy', () => { + let registry: SchemaRegistry; + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + }); + + it('answers the permission-set names a package declared, and no other package\'s', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user', 'tasks_admin'])); + registry.installPackage(manifest('com.acme.other', ['other_user'])); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual(['tasks_admin', 'tasks_user']); + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'position')).toEqual([]); + }); + + it('a disabled package still holds its names', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user'])); + registry.disablePackage('com.acme.tasks'); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual(['tasks_user']); + }); + + it('nothing once the package is uninstalled — which is why the doors read it first', () => { + registry.installPackage(manifest('com.acme.tasks', ['tasks_user'])); + registry.uninstallPackage('com.acme.tasks'); + + expect(registry.securityCatalogNamesHeldBy('com.acme.tasks', 'permission')).toEqual([]); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 45723344c89..126651adad6 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2489,6 +2489,29 @@ export class SchemaRegistry { } } + /** + * Every `type` name `packageId` holds in the security catalog: its claims, + * its composite `:` item slots, and any bare slot stamped + * with it — sorted, and unfiltered by disable (a disabled package is still + * installed, and still holds its names). + * + * Read by the uninstall doors just BEFORE {@link uninstallPackage}, which + * releases all of it: the cleanups run after the withdrawal, and this is the + * last moment the names are known (ADR-0090 D5, ADR-0131 D4). + */ + securityCatalogNamesHeldBy(packageId: string, type: SecurityCatalogType): string[] { + const names = new Set(); + for (const [name, claimant] of this.securityCatalogClaims.get(type) ?? []) { + if (claimant === packageId) names.add(name); + } + const prefix = `${packageId}:`; + for (const [key, item] of this.metadata.get(type) ?? []) { + if (key.startsWith(prefix)) names.add(key.slice(prefix.length)); + else if ((item as { _packageId?: unknown } | null | undefined)?._packageId === packageId) names.add(key); + } + return [...names].sort(); + } + /** * The cold-boot half of the rule (`security-catalog-namespace.ts`, "The cold * boot"; maintainer ruling letter A on #22307, record 6063176077): every diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index b2c666f58b2..70b8d20d101 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -150,3 +150,33 @@ describe('cleanupPackagePermissions (#2747)', () => { }); }); }); + +describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declared', () => { + // A set served from the registry has no row: the door hands the cleanup the + // names it captured before the withdrawal, and the grants naming them go. + function withNamedGrants() { + const ql = makeQl(); + ql.tables.sys_user_permission_set.push( + { id: 'ups_named_1', user_id: 'u2', permission_set: 'crm_catalog_user' }, + { id: 'ups_named_2', user_id: 'u3', permission_set: 'crm_catalog_user' }, + { id: 'ups_named_other', user_id: 'u2', permission_set: 'other_catalog_set' }, + ); + return ql; + } + + it('revokes every user grant naming a declared set, and only those', async () => { + const ql = withNamedGrants(); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { permission: ['crm_catalog_user'] }); + + expect(out.userGrants).toBe(3); // the legacy row's grant (ups_1) and the two naming the declared set + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_other']); + }); + + it('control — handed no names, a grant naming a catalog set survives: the cleanup is as it was', async () => { + const ql = withNamedGrants(); + const out = await cleanupPackagePermissions(ql, 'com.example.crm'); + + expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_1', 'ups_named_2', 'ups_named_other']); + }); +}); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts index 0d9d2eb35e8..0287a152e7b 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts @@ -110,6 +110,7 @@ export async function cleanupPackagePermissions( ql: any, packageId: string, logger?: { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }, + declared?: { permission?: readonly string[] }, ): Promise { const out: PackagePermissionCleanupOutcome = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0, @@ -151,6 +152,21 @@ export async function cleanupPackagePermissions( } out.sets = await deleteRows(ql, 'sys_permission_set', sets, refusals); + // [ADR-0131 D3/D4, ADR-0090 D5] The package's sets are served from the + // registry, so no row above stands for them: the grants naming them are + // reached by the names the uninstall door captured before it withdrew the + // package. A name belongs to one holder (the registry refuses a second at + // install), so every grant naming it was a grant of this package's set. + // Position bindings have no name column; a legacy one is reached above. + for (const name of new Set(declared?.permission ?? [])) { + if (typeof name !== 'string' || name === '') continue; + out.userGrants += await deleteRows( + ql, 'sys_user_permission_set', + await tryFind(ql, 'sys_user_permission_set', { permission_set: name }), + refusals, + ); + } + // Suggestion rows in every status — reinstall re-prompts fresh (D5). out.suggestions = await deleteRows( ql, 'sys_audience_binding_suggestion', diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8081ed51060..74ddadadc84 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4503,13 +4503,15 @@ export class SecurityPlugin implements Plugin { } // [#2747] When the owning package is uninstalled, revoke its data-plane permission // rows (package-owned sets + their position/user bindings + the - // package's suggestion rows) so grants die with the package — the - // "no ghost grants" clause of ADR-0090 D5. + // package's suggestion rows) and the grants naming the sets it + // declared (`args.declared`, captured by the door before it withdrew + // the package) so grants die with the package — the "no ghost + // grants" clause of ADR-0090 D5. if (protocol && typeof protocol.registerUninstallCleanup === 'function') { protocol.registerUninstallCleanup( 'security.package-permissions', - async (args: { packageId: string }) => { - const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger); + async (args: { packageId: string; declared?: { permission?: string[] } }) => { + const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger, args.declared); // [#22801 → A] The package's acceptance rows are gone with it; // `everyone` is derived again without its sets. await this.declareEveryone(ctx.logger); diff --git a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts new file mode 100644 index 00000000000..9bd40b4eb34 --- /dev/null +++ b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0090 D5 ("no ghost grants") on the protocol's own uninstall door, +// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no door writes a +// `sys_permission_set` row: a package's set is served by the security catalog +// from the registry, and a grant names it. +// +// The withdrawal (`registry.uninstallPackage`) runs before the uninstall +// cleanups and releases the package's names, so the door captures the +// permission-set names the package held just before it, and plugin-security's +// cleanup revokes the grants naming them. Without that hand-off the grant +// survives the uninstall, inert while nothing serves the name, and is live +// again the moment the package publishes the set again. The install-local door +// is pinned end to end by the CLI suite +// `package-install-local-uninstall-cleanups.integration.test.ts`. +// +// Real stack: the CRM example over the real SecurityPlugin; the package is a +// writable base that publishes its own set (ADR-0070), and the uninstall is +// `deletePackage` on it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true }; +const BASE = 'app.dogfood_uninstall_grants'; +const SET = 'dfu_task_user'; +const USER = 'usr_dfu_grantee'; + +describe('dogfood: deletePackage revokes the grants naming the package\'s permission sets (ADR-0090 D5)', () => { + let stack: VerifyStack; + let protocol: any; + let ql: any; + + const publishSet = () => protocol.saveMetaItem({ + type: 'permission', + name: SET, + item: { name: SET, label: 'DFU Task User', objects: { crm_lead: { allowRead: true } } }, + packageId: BASE, + mode: 'publish', + }); + const grants = async () => + ((await ql.find('sys_user_permission_set', { where: { permission_set: SET }, context: SYS })) ?? []) as any[]; + + let granted: any; + let uninstall: any; + let afterUninstall: { served: unknown; grants: any[] }; + let afterRepublish: { served: unknown; grants: any[] }; + + beforeAll(async () => { + stack = await bootStack(crmStack); + protocol = await stack.kernel.getServiceAsync('protocol'); + ql = await stack.kernel.getServiceAsync('objectql'); + + await ql.insert('sys_user', { id: USER, email: 'dfu@example.test', name: 'DFU', email_verified: true }, { context: SYS }); + await publishSet(); + granted = await ql.insert('sys_user_permission_set', { user_id: USER, permission_set: SET }, { context: SYS }); + + uninstall = await protocol.deletePackage({ packageId: BASE }); + afterUninstall = { served: await catalogPermissionSet(ql, SET), grants: await grants() }; + + // The same package publishes the same set again. + await publishSet(); + afterRepublish = { served: await catalogPermissionSet(ql, SET), grants: await grants() }; + }, 120_000); + afterAll(async () => { + await stack?.stop?.(); + }); + + it('precondition: the package\'s set was served and the user held a grant naming it', () => { + expect(granted?.id).toBeTruthy(); + expect(granted?.permission_set).toBe(SET); + }); + + it('the uninstall reports the security cleanup, and the grant is gone with the set', () => { + const security = (uninstall?.cleanups ?? []).find((o: any) => o?.name === 'security.package-permissions'); + expect(security, JSON.stringify(uninstall)).toMatchObject({ success: true }); + expect(security.removed).toBeGreaterThanOrEqual(1); + expect(afterUninstall.served).toBeUndefined(); + expect(afterUninstall.grants).toEqual([]); + }); + + it('the set published again is served again — and the revoked grant does not come back with it', () => { + expect((afterRepublish.served as any)?.name).toBe(SET); + expect(afterRepublish.grants).toEqual([]); + }); +}); From cfffec83568ce3360dcfe30cc9c8eddfe78962be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:55:58 +0000 Subject: [PATCH 19/25] test(runtime,cli): read a package's permission set from the catalog, not a seeded row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration-copy measurement asserts no `sys_permission_set` row is written for a declared set (ADR-0131 D3), as it already did for a capability. The hot-install boot-steps suite reads the set from `GET /meta/permission/…`, which the grant form lists. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- ...stall-local-boot-steps.integration.test.ts | 18 ++++++---- ...lone-stack-seeder-declaration-copy.test.ts | 35 +++---------------- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts index 57fa4461226..21561e5c173 100644 --- a/packages/cli/test/package-install-local-boot-steps.integration.test.ts +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -3,8 +3,8 @@ /** * #21322 — a hot install (`os package install ` into a RUNNING * `os start`) leaves the runtime in the state a restart would: the installed - * package's record-change flow fires and its permission set is projected into - * `sys_permission_set`, both right after the install, with no restart. + * package's record-change flow fires and its permission set is served by the + * security catalog, both right after the install, with no restart. * * ## The defect, measured on the published train and again on `main` * @@ -23,6 +23,11 @@ * sweeps run once per boot; nothing re-ran them for a package that arrived * after it. * + * Since ADR-0131 C3 stage 6b-2 no door writes a `sys_permission_set` row: the + * set is served by the security catalog from the registry, and a grant names + * it. What an admin grants from is `GET /meta/permission/…`, so that is what + * the permission-set case reads now. + * * ## What each `it` reads * * One fixture, three phases — after the hot INSTALL, after a RESTART on the @@ -268,7 +273,7 @@ function rowsOf(answer: Answer): any[] { const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body; interface Phase { - /** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */ + /** `GET /meta/permission/…` — the set as the security catalog serves it, which the grant form lists (ADR-0131 D3). */ permissionSet: Answer; /** The task the flow is driven through: created, then updated to `done`. */ created: Answer; @@ -279,7 +284,7 @@ interface Phase { let seq = 0; async function probe(live: LiveStart, token: string): Promise { - const permissionSet = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token); + const permissionSet = await http(live, 'GET', `/api/v1/meta/permission/${PERMISSION_SET}`, token); const taskName = `flow-probe-${++seq}`; const created = await http(live, 'POST', `/api/v1/data/${TASK}`, token, { name: taskName, status: 'open' }); @@ -352,10 +357,11 @@ describe('#21322: a hot install binds what the boot binds', () => { for (const name of ['install', 'restart', 'control'] as const) { describe(`after ${name}`, () => { - it('the package permission set is projected into sys_permission_set', () => { + it('the package permission set is served by the security catalog', () => { const p = phases[name]!; expect(p.permissionSet.status, JSON.stringify(p.permissionSet.body)).toBe(200); - expect(rowsOf(p.permissionSet).map((r) => r?.name)).toEqual([PERMISSION_SET]); + expect((p.permissionSet.body?.item ?? p.permissionSet.body?.data?.item)?.name, JSON.stringify(p.permissionSet.body)) + .toBe(PERMISSION_SET); }); it('the record-change flow fires: a task updated to done writes its note', () => { diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a33e457181a..3edfdf2cede 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -13,9 +13,9 @@ // copies, and the plugin-security / plugin-sharing seeders read the registry // one FIRST: // -// bootstrap-declared-permissions.ts readDeclared(ql, 'permission') then metadataService.list -// (bootstrap-declared-capabilities.ts did the same for 'capability' until -// ADR-0131 D3 retired it: a declared capability now has no row at all) +// (bootstrap-declared-permissions.ts did the same for 'permission', and +// bootstrap-declared-capabilities.ts for 'capability', until ADR-0131 D3 +// retired them: a declared permission set or capability now has no row at all) // bootstrap-declared-sharing-rules.ts readDeclared(engine,'sharing_rule') then metadataService.list // // Triage (14491#issuecomment-5507909226) ruled the card down to four questions @@ -383,33 +383,8 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(door).toMatchObject({ isDefault: false, _packageVersion: '3.0.0' }); }); - it('⚠️ NOT ABSORBED — `sys_permission_set` persists the UN-PARSED registry copy, byte for byte', () => { - const reg = byName(registryCopy.permission, 'probe_agent'); - const door = byName(doorCopy.permission, 'probe_agent'); - const row = byName(rows.sys_permission_set, 'probe_agent'); - - // The decisive read: the stored authorization map is deep-equal to the - // registry copy's and NOT to the door's. Nothing between `readDeclared` and - // `tryInsert` re-parses it — `permissionSetRowFields` serialises what it - // was handed. - const storedObjects = JSON.parse(row.object_permissions); - expect(storedObjects).toEqual(reg.objects); - expect(storedObjects).not.toEqual(door.objects); - - const storedRls = JSON.parse(row.row_level_security); - expect(storedRls).toEqual(reg.rowLevelSecurity); - expect(storedRls).not.toEqual(door.rowLevelSecurity); - - // Spelled out key by key, so a future change to either side names itself. - expect(storedObjects.crm_ticket).toMatchObject({ allowRestore: true, allowPurge: false }); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowTransfer'); - expect(storedObjects.crm_ticket).not.toHaveProperty('viewAllRecords'); - expect(storedObjects.crm_ticket).not.toHaveProperty('modifyAllRecords'); - expect(storedRls[0]).toMatchObject({ priority: 10 }); - - expect(row).toMatchObject({ - managed_by: 'package', package_id: 'com.test.issue-14491', active: true, customized: false, - }); + it('no row: a declared permission set is served by the registry and no `sys_permission_set` row is written for it (ADR-0131 D3)', () => { + expect(rows.sys_permission_set.filter((r: any) => r.name === 'probe_agent')).toEqual([]); }); it('`sharing_rule`: the registry copy keeps the legacy `sharedWith.type` and `accessLevel` the door forward-converts', () => { From 8e40fb06bbc82ca3fe29bd901de34483b1ac60a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:26:05 +0000 Subject: [PATCH 20/25] test(plugin-auth): the grant-reader suites write the legacy platform-bucket rows they read The plugin-auth twins of the plugin-security grant-reader suites read the organization-less `sys_permission_set` rows the platform-admin bootstrap seeded (organization_admin to clone per organization; viewer_readonly to point a legacy id at). Once the boot writes no such row, those reads come back empty. Each suite now models the existing database it describes and writes the rows itself, before the bootstrap, through a plugin-auth copy of the plugin-security testkit: same shape, organization-less, idempotent. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../__tests__/legacy-catalog-rows.testkit.ts | 61 +++++++++++++++++++ .../src/grant-readers-by-name.golden.test.ts | 3 + .../src/grant-readers-unnamed-grant.test.ts | 3 + 3 files changed, 67 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts new file mode 100644 index 00000000000..2da6b0c4017 --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the organization-less `sys_permission_set` rows an EARLIER + * release's platform-admin bootstrap seeded for the platform's default sets + * (the "platform bucket"). + * + * [C3 stage 6b-2] No boot writes these rows any more (ADR-0131 D3: the sets are + * served from the security catalog, and a grant names its set). A suite that + * models an EXISTING database — a grant written before the name column, whose + * `permission_set_id` points at one of these rows; an organization's copy cloned + * from the bucket — writes them itself, here, in the shape the retired seeder + * wrote: `managed_by: 'platform'`, active, the set's facets as JSON columns. + * Idempotent: a name that already has an organization-less row is left alone. + * + * The plugin-auth copy of plugin-security's testkit of the same name, in the + * same shape: a test-only helper is not exported across the package boundary. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; + +/** The legacy row id for a default set — stable, so a fixture can point a grant at it. */ +export function legacyBucketRowId(name: string): string { + return `ps_bucket_${name}`; +} + +/** Write the legacy platform-bucket row for each set; returns `name → id`. */ +export async function seedLegacyPlatformBucket( + engine: { find: (...args: any[]) => Promise; insert: (...args: any[]) => Promise }, + sets: readonly PermissionSet[], +): Promise> { + const ids: Record = {}; + for (const ps of sets) { + if (!ps.name) continue; + const existing = ((await engine.find('sys_permission_set', { where: { name: ps.name }, context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own?.id) { + ids[ps.name] = String(own.id); + continue; + } + const id = legacyBucketRowId(ps.name); + await engine.insert('sys_permission_set', { + id, + name: ps.name, + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + active: true, + managed_by: 'platform', + }, { context: SYS }); + ids[ps.name] = id; + } + return ids; +} diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 93ef93b5a02..7d43ddfc464 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -40,6 +40,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -153,6 +154,8 @@ async function standingByReader(posture: Posture): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine }; From bf1eb8df765ec165177442f90c433184e8e9b4d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:55:18 +0000 Subject: [PATCH 21/25] test(cloud-connection): resolve @objectstack/metadata-protocol from source in the unit tests The install-local uninstall now captures the package's permission-set names through `captureUninstallDeclared`, a runtime import of `@objectstack/metadata-protocol`, so the suites that import the plugin would judge `metadata-protocol/dist` rather than this checkout (`check:test-source-alias`). One anchored alias entry, as the others. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- packages/cloud-connection/vitest.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 9206035c576..8fee1b883e4 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -59,6 +59,15 @@ export default defineConfig({ find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../objectql/src/index.ts'), }, + // [#15204 6b-2, ADR-0090 D5] The install-local uninstall captures the + // package's permission-set names through `captureUninstallDeclared`, so + // every suite that imports the plugin judges THIS checkout's capture, not + // a `metadata-protocol/dist` that may be behind it. Anchored like the + // entries above, though the package exports only `.` today. + { + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". From ecfb54f8b8044f328883d5e42496a480f23c0e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:04:41 +0000 Subject: [PATCH 22/25] chore(census): restamp the tenant-audit census after merging main Regenerated with `node scripts/tenant-audit-census.mjs --write`; the population is unchanged at 225, only the measuring commit moves. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- content/docs/permissions/tenant-audit-census.mdx | 2 +- docs/audits/2026-08-tenant-audit-write-call-sites.counts.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 2893a867698..96542330024 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -297,7 +297,7 @@ 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-12 at `5adf11374`. +Measured on 2026-10-12 at `c60e08810`. | corpus scale (not enforced) | count | | :--- | ---: | 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 ae14aaa7bef..6a61d0062ff 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 @@ -90,7 +90,7 @@ 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-12 at `5adf11374`. +Measured on 2026-10-12 at `c60e08810`. | corpus scale (not enforced) | count | | :--- | ---: | From 5cc2ef862938d418a376b63b06fe69937a170027 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:27:04 +0000 Subject: [PATCH 23/25] docs(permissions): describe the catalog-served package sets, not the retired seeder and materializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text only, from the contract review's flag ③3: `authorization.mdx`'s package-door paragraph, `permission-model.md`'s install sentence, and three code comments (the name-collision report, install-local's `metadata:reloaded` note, `sys_permission_set.managed_by`) spoke of the seeder, the publish materializer and the managed_by normalizer in the present tense. They now describe the catalog serving the package's sets, or name those writers as retired. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- content/docs/permissions/authorization.mdx | 13 +++++++------ docs/design/permission-model.md | 7 ++++--- .../cli/src/utils/permission-set-name-collisions.ts | 12 +++++++----- .../src/marketplace-install-local-plugin.ts | 8 ++++---- .../src/objects/sys-permission-set.object.ts | 5 +++-- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index a9306e576c2..9eae81ea1c3 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -224,12 +224,13 @@ one of two doors, each writing only what it owns: - **Package door** (studio `/studio/:packageId/access`) — a package's own set is **metadata**, so edits are saved as a **draft** stamped with the `packageId` (`saveMetaItem` `mode:'draft'`) and go live with the package's atomic - **Publish**, exactly like Data and Interfaces. On publish, a registered - materializer (`registerPublishMaterializer`) projects the published body into - `sys_permission_set` as `managedBy:'package'` + `packageId`, reusing the same - upsert as the boot seeder (`upsertPackagePermissionSet`). Enforcement is - unaffected while a set is still a draft (drafts never enter the active - resolve). + **Publish**, exactly like Data and Interfaces. On publish, the security + catalog serves the published body from the registry, owned by the package + (ADR-0131 D3); no `sys_permission_set` row is written. The landed publish + fires `onMetadataMutation`, which runs the audience-binding suggestion + reconcile and re-derives the `everyone` anchor's accepted package defaults. + Enforcement is unaffected while a set is still a draft (drafts never enter + the active resolve). - **Environment-admin door** (`metadata-admin`) — the cross-package all-objects matrix plus subject **assignment** (`sys_user_permission_set`), edited **live** (config); the sets a position distributes are its definition's diff --git a/docs/design/permission-model.md b/docs/design/permission-model.md index b0260fbda2b..6a921f751aa 100644 --- a/docs/design/permission-model.md +++ b/docs/design/permission-model.md @@ -162,9 +162,10 @@ Three developer disciplines: - **Restrict by not granting**: in a union model a `readable: false` cannot defeat another set's `true`. There are no subtraction sets — to withhold, don't grant. -On install, `bootstrapDeclaredPermissions` seeds these rows with `packageId` + -`managedBy: 'package'`; upgrades re-seed **only** those rows. Customer-authored sets and all -bindings are never touched — that provenance line *is* the package/platform isolation boundary. +On install, the security catalog serves these sets from the package's registered metadata, owned +by the package (ADR-0131 D3); no row is seeded, and an upgrade serves the definitions the new +version ships. Customer-authored sets and all bindings are never touched — that provenance line +*is* the package/platform isolation boundary. ### What the admin configures after install diff --git a/packages/cli/src/utils/permission-set-name-collisions.ts b/packages/cli/src/utils/permission-set-name-collisions.ts index dbf66ed7771..eab626f6252 100644 --- a/packages/cli/src/utils/permission-set-name-collisions.ts +++ b/packages/cli/src/utils/permission-set-name-collisions.ts @@ -7,9 +7,11 @@ * * ## The defect this closes, one layer up from #17516 * - * `bootstrapDeclaredPermissions` refuses to write into a `sys_permission_set` - * row another package owns (ADR-0086 D4). That refusal is CORRECT and nothing - * here changes it — ⛔ this module reports, it does not alter what is skipped. + * `bootstrapDeclaredPermissions` refused to write into a `sys_permission_set` + * row another package owned (ADR-0086 D4). That seeder retired with ADR-0131 + * C3 stage 6b-2; the registry now refuses, at install, a package declaring a + * set name another holder already holds. Nothing here changes either refusal — + * ⛔ this module reports, it does not alter what is refused. * #17516 gave that refusal a RUNTIME door. Until this module there was no * compile-time door at all: `os build` and `os validate` walked an artifact in * which two packages declared the same set name and said nothing, so the first @@ -62,8 +64,8 @@ * * ## The provenance rule, mirrored from the seeder rather than invented * - * `bootstrapDeclaredPermissions` owns a set under - * `ps._packageId ?? ps.packageId` — registry provenance (ADR-0010) first, the + * `bootstrapDeclaredPermissions` (retired with ADR-0131 C3 stage 6b-2) owned a + * set under `ps._packageId ?? ps.packageId` — registry provenance (ADR-0010) first, the * author-declared `packageId` (ADR-0086 D3) as fallback. At compile time the * artifact's package id IS that registry provenance: `artifactPackages`' id is * "the string the runtime registers a package under", so a set inside package diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index c8a6fa89101..74cac8bfd53 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -2847,10 +2847,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * - `service-automation` re-syncs its flows from the protocol * (`resyncFlowsFromProtocol`, the `kernel:ready` bind's own * `registerFlow`), so the package's record-change flows fire; - * - `plugin-security` re-runs its declared-permission seeding (the - * `kernel:ready` pass's own `bootstrapDeclaredPermissions`), so the - * package's permission sets are projected into `sys_permission_set` - * and can be granted. + * - `plugin-security` re-ran its declared-permission seeding here until + * ADR-0131 C3 stage 6b-2 retired it. A package's permission sets are + * now served by the security catalog from the registry as soon as the + * install registers them, so nothing re-runs for them. * * Before this, a hot install left both until the next restart: the * restart's rehydrate registers the package ahead of those sweeps, a hot diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 33eff4d50b1..1905c02850b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -280,8 +280,9 @@ export const SysPermissionSet = ObjectSchema.create({ // constrained `select` so the value is a labelled, i18n-able badge. // Back-compat: legacy env rows carry 'user' (== admin). No runtime path // branches on 'user' (every read keys on 'package' / 'platform', both - // unchanged), so those rows stay semantically correct; the boot normalizer - // (`normalizeManagedByVocab`) rewrites them to 'admin' idempotently. + // unchanged), so those rows stay semantically correct. The boot normalizer + // that rewrote them to 'admin' (`normalizeManagedByVocab`) retired with + // ADR-0131 C3 stage 6b-2, so such rows keep 'user'. managed_by: Field.select({ label: 'Managed By', required: false, From 70a42bf2f52a36024a1f33d902c550f932e2a5d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:38:11 +0000 Subject: [PATCH 24/25] docs(permissions): say what the ADR-0094 projector still writes on a publish; retire the remaining seeder prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delta review ③3-a: no seeder or materializer writes a `sys_permission_set` row, but until stage 7 retires the ADR-0094 projector a permission-set publish (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id`, and the boot reconcile re-creates it when missing. No authorization path reads it, and uninstalling the package removes it. `authorization.mdx`, the changeset and the deletePackage dogfood header now say so. ③3-b: the remaining present-tense mentions of the retired seeder and materializer are reworded: `authorization.mdx`'s isSystem bypass and the lifecycle table; `sys_permission_set`'s `package_id` and `managed_by` descriptions (English bundle regenerated with `check-i18n-bundles --write`); install-local's two metadata:reloaded warnings, which no longer claim permission sets wait for a restart; and comments in `protocol.ts`, `permission-set-projection.ts`, `packaged-permission-set-lock.ts` and `permission-set-name-collision.ts`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../15204-s6b2-declared-permission-seeders.md | 4 ++-- content/docs/permissions/authorization.mdx | 20 +++++++++++-------- .../src/marketplace-install-local-plugin.ts | 4 ++-- packages/metadata-protocol/src/protocol.ts | 7 ++++--- .../src/objects/sys-permission-set.object.ts | 8 ++++---- .../src/packaged-permission-set-lock.ts | 2 +- .../src/permission-set-name-collision.ts | 7 ++++--- .../src/permission-set-projection.ts | 2 +- .../src/translations/en.objects.generated.ts | 4 ++-- ...stall-revokes-named-grants.dogfood.test.ts | 9 ++++++--- 10 files changed, 38 insertions(+), 29 deletions(-) diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index e2776c1c69d..abf718276c7 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -5,7 +5,7 @@ '@objectstack/cloud-connection': patch --- -feat(plugin-security)!: the boot writes no `sys_permission_set` row — the declared-permission seeder and the platform-default materialization are deleted +feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted Clause-②: yes (narrowing) @@ -13,7 +13,7 @@ Clause-②: yes (narrowing) **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. -`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a package-door publish, a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. +`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 9eae81ea1c3..db0bf414f31 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -226,11 +226,15 @@ one of two doors, each writing only what it owns: (`saveMetaItem` `mode:'draft'`) and go live with the package's atomic **Publish**, exactly like Data and Interfaces. On publish, the security catalog serves the published body from the registry, owned by the package - (ADR-0131 D3); no `sys_permission_set` row is written. The landed publish - fires `onMetadataMutation`, which runs the audience-binding suggestion - reconcile and re-derives the `everyone` anchor's accepted package defaults. - Enforcement is unaffected while a set is still a draft (drafts never enter - the active resolve). + (ADR-0131 D3). The landed publish fires `onMetadataMutation`, which runs the + audience-binding suggestion reconcile and re-derives the `everyone` anchor's + accepted package defaults. Until stage 7 retires the ADR-0094 projector, the + publish (a package-door draft publish, or a writable base's direct publish) + also has the projector write a `sys_permission_set` projection row, + `managedBy:'admin'` with no `packageId`, and the boot reconcile re-creates it + if it is missing. No authorization path reads that row, and uninstalling the + package removes it. Enforcement is unaffected while a set is still a draft + (drafts never enter the active resolve). - **Environment-admin door** (`metadata-admin`) — the cross-package all-objects matrix plus subject **assignment** (`sys_user_permission_set`), edited **live** (config); the sets a position distributes are its definition's @@ -266,8 +270,8 @@ one of two doors, each writing only what it owns: `allowRuntimeCreate` tier and stays editable. A "delete" of a packaged set through this door still degrades to a **reset** to the shipped declaration — the admin door can never remove a packaged definition. System / - boot writes carry `isSystem` and bypass it, so the seeder and materializer - are never self-blocked. That bypass is not local to this gate — the full set + boot writes carry `isSystem` and bypass it (the ADR-0094 projector's among + them), so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set of behaviours the flag changes is catalogued in [System Context (`isSystem`)](/docs/permissions/system-context). @@ -275,7 +279,7 @@ one of two doors, each writing only what it owns: | Stage | What holds today | Owned by | |---|---|---| -| 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 | +| 1 · Package development | Zod-validated authoring; sharingRules seeded at boot with provenance; permission sets and positions served from the registry, with no seeded row (ADR-0131 D2/D3) | 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/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 74cac8bfd53..d9f916ec941 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -2870,7 +2870,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (typeof ctx.trigger !== 'function') { ctx.logger?.warn?.( `[MarketplaceInstallLocal] this kernel context cannot announce metadata:reloaded — the record-change flows ` - + `and permission sets of ${manifestId} take effect only after a restart.`, + + `of ${manifestId} take effect only after a restart.`, ); return; } @@ -2879,7 +2879,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] ${manifestId} is installed, but the metadata:reloaded re-sync FAILED — its ` - + 'record-change flows may not fire and its permission sets may be missing from sys_permission_set ' + + 'record-change flows may not fire ' + `until the runtime restarts (the restart re-reads every installed package). Cause: ${err?.message ?? err}`, ); } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 34552e27e60..adb3ce06b9b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22498,9 +22498,10 @@ export class ObjectStackProtocolImplementation implements ); } } - // [ADR-0094] Awaited projection: runs AFTER the package-door - // materializer (which stamps package provenance) so the projector - // sees final record state; refuses/no-ops per its own rules. + // [ADR-0094] Awaited projection: runs AFTER any materializer + // registered for the type, so the projector sees final record state; + // refuses/no-ops per its own rules. (No `permission` materializer is + // registered since ADR-0131 C3 stage 6b-2.) const publishProjection = await this.runMutationProjector({ type: args.singularType, name: args.name, diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index 1905c02850b..b9ac9972ca5 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -270,7 +270,7 @@ export const SysPermissionSet = ObjectSchema.create({ maxLength: 255, description: 'Package that ships this permission set (absent = environment-authored). ' + - 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', + 'Set on rows an earlier release seeded for a package\'s set; no current writer sets it.', group: 'Provenance', }), @@ -291,9 +291,9 @@ export const SysPermissionSet = ObjectSchema.create({ description: "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + "permission sets and positions all share: 'platform' = shipped by the " + - "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + - "for admins); 'admin' = created/owned in this environment by an administrator " + - "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + "platform; 'package' = a row an earlier release seeded for a package's set " + + "(read-mostly for admins); 'admin' = created/owned in this environment by an administrator " + + "(live-edited). Legacy rows may carry 'user' (== admin).", options: [ { value: 'platform', label: 'Platform' }, { value: 'package', label: 'Package' }, diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 34a2f9eaccc..7c399f10d5b 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -46,7 +46,7 @@ * page in it at all. * * ⭐ The answer comes from the engine's SchemaRegistry instead — the same - * source `bootstrapDeclaredPermissions`' {@link readDeclared} already uses, + * source {@link readDeclared} (the retired `bootstrapDeclaredPermissions`' read) uses, * so this plugin keeps ONE spelling of "package-declared" rather than two (Prime * Directive #8). That read is an in-memory array: no page, no cap, no `$in`, * no driver. Truncation is not a failure mode it HAS, which is a structural diff --git a/packages/plugins/plugin-security/src/permission-set-name-collision.ts b/packages/plugins/plugin-security/src/permission-set-name-collision.ts index 5aa5ff77e88..10d20d61bcb 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.ts @@ -7,9 +7,10 @@ * * ## What was wrong * - * `bootstrapDeclaredPermissions` refuses to write into a `sys_permission_set` - * row another package owns (ADR-0086 D4: a package never writes into a foreign - * record). That refusal is CORRECT and is unchanged here. What was wrong is + * `bootstrapDeclaredPermissions` refused to write into a `sys_permission_set` + * row another package owned (ADR-0086 D4: a package never writes into a foreign + * record); it retired with ADR-0131 C3 stage 6b-2, and the registry now refuses + * a second holder of a set name at install. What was wrong is * that it was INVISIBLE: the whole declared set vanished with an internal * counter incremented and one `logger?.warn?.(…)` line — optionally chained * TWICE, so a caller that passed no logger produced no output at all. The diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index ee62421a10c..c38888ee3d1 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -476,7 +476,7 @@ const isProjectionEcho = (v: any): boolean => /** * Read the DECLARED (artifact) body for a permission set from the engine's - * SchemaRegistry — the same source `bootstrapDeclaredPermissions` seeds from, + * SchemaRegistry — the same source the retired `bootstrapDeclaredPermissions` seeded from, * and the one store the env projection never writes, so it can't be poisoned * by our own registry sync. It is the body for a declared set with no overlay * at all, and the reset target when an overlay IS lifted off one — which since diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index efbd77c2f66..e2cbd494c0b 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -226,11 +226,11 @@ export const enObjects: NonNullable = { }, package_id: { label: "Owning Package", - help: "Package that ships this permission set (absent = environment-authored). Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined." + help: "Package that ships this permission set (absent = environment-authored). Set on rows an earlier release seeded for a package's set; no current writer sets it." }, managed_by: { label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = a row an earlier release seeded for a package's set (read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited). Legacy rows may carry 'user' (== admin).", options: { platform: "Platform", package: "Package", diff --git a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts index 9bd40b4eb34..196ea52b24c 100644 --- a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts @@ -1,9 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0090 D5 ("no ghost grants") on the protocol's own uninstall door, -// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no door writes a -// `sys_permission_set` row: a package's set is served by the security catalog -// from the registry, and a grant names it. +// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no seeder or +// materializer writes a `sys_permission_set` row: a package's set is served by +// the security catalog from the registry, and a grant names it. (Until stage 7 +// retires the ADR-0094 projector, the publish below still has it write an +// `admin` projection row that no authorization path reads; the uninstall +// removes it.) // // The withdrawal (`registry.uninstallPackage`) runs before the uninstall // cleanups and releases the package's names, so the door captures the From 320ce20b743c40d6908ae9fb83ccc2e1efba3cec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:39:33 +0000 Subject: [PATCH 25/25] docs(permissions): the environment-admin bullet points at the transitional projection row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Environment-admin bullet's "pure projection … re-derived on every metadata mutation" now notes that a package's published set gets such a row too until stage 7, the transitional `admin` row the Package-door bullet describes, so the two bullets agree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- content/docs/permissions/authorization.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index db0bf414f31..bccf3c94995 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -245,7 +245,9 @@ one of two doors, each writing only what it owns: CRUD) is transparently redirected into an env-scope metadata save (`saveMetaItem`), and the data record is a **pure projection** the platform re-derives on every metadata mutation (awaited — no staleness window) and at - boot. Under a `group` or `isolated` tenancy posture that Setup write is the + boot. A package's published set gets such a row too, until stage 7 — the + transitional `managedBy:'admin'` row described in the Package-door bullet + above. Under a `group` or `isolated` tenancy posture that Setup write is the platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its declared body rather than removing it; renaming through the data door is