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-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-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/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/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/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-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/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index d912893a03e..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 @@ -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. @@ -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/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); - }); -}); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 9f0e7a85b99..e9fb173732b 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -376,14 +376,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", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index a70b517c6e4..5eb11eee029 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3126,11 +3126,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", @@ -3186,16 +3181,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "findOne", @@ -3306,11 +3291,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", @@ -3331,11 +3311,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", @@ -3346,11 +3321,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", @@ -3486,11 +3456,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 b4983ca2c57..6b790a043a5 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -573,27 +573,18 @@ "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 }, @@ -603,9 +594,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 },