From 7a5ef33459b9f7d381a32d6bc95a559ba7a4eece Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:05:13 +0000 Subject: [PATCH 01/39] wip(plugin-security): retire the declared-permission seeder, drift, refusal diagnostics and managed_by normalizer (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../bootstrap-declared-permissions.test.ts | 655 ------------------ .../src/bootstrap-declared-permissions.ts | 522 -------------- .../src/bootstrap-seed-round-trips.test.ts | 368 ---------- .../src/builtin-capabilities.boot.test.ts | 2 - .../src/declared-capability-context.ts | 26 +- ...lared-permission-reload-projection.test.ts | 160 ----- .../src/engine-find-bare-array.pin.test.ts | 53 +- packages/plugins/plugin-security/src/index.ts | 45 +- .../src/normalize-managed-by.test.ts | 77 -- .../src/normalize-managed-by.ts | 196 ------ .../src/per-organization-catalog.test.ts | 240 +------ .../src/permission-set-drift.test.ts | 379 ---------- .../src/permission-set-drift.ts | 355 ---------- .../src/permission-set-name-collision.test.ts | 255 +------ .../src/read-fault-distinguishability.test.ts | 133 +--- .../plugin-security/src/security-plugin.ts | 344 ++------- ...ed-name-lookup-degradation-audible.test.ts | 12 +- .../src/seed-refusal-diagnostics.ts | 393 ----------- .../walled-platform-bucket-diagnostic.test.ts | 387 ----------- 19 files changed, 115 insertions(+), 4487 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts delete mode 100644 packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts delete mode 100644 packages/plugins/plugin-security/src/normalize-managed-by.test.ts delete mode 100644 packages/plugins/plugin-security/src/normalize-managed-by.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-drift.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-drift.ts delete mode 100644 packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts delete mode 100644 packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts deleted file mode 100644 index 6eeb04de759..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { describe, it, expect, vi } from 'vitest'; -import { - bootstrapDeclaredPermissions, - upsertPackagePermissionSet, -} from './bootstrap-declared-permissions.js'; -import { - PERMISSION_SET_DECLARATION_UNOWNED, - PERMISSION_SET_ROWS_UNREADABLE, -} from './seed-refusal-diagnostics.js'; -import { permissionSetRowFields } from './permission-set-projection.js'; - -/** [#18091] Seeded from this file, for the class pin at the bottom. */ -const HERE = dirname(fileURLToPath(import.meta.url)); - -/** Minimal in-memory ql + registry for sys_permission_set seeding. */ -function makeQl(declared: any[] = []) { - const rows: any[] = []; - return { - rows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - // Membership is modelled because the real engine supports it and the - // #10946 boot seeders now hoist ONE `$in` existence read out of their - // loop. A double that silently answered `[]` to `$in` would report - // "nothing is seeded" and make every re-seed look like a first boot. - return rows.filter((r) => Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return r[k] === v; - })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - fields: { 'crm_lead.amount': { readable: true, editable: false } }, - systemPermissions: ['crm.use'], - _packageId: 'com.example.crm', - ...over, -}); - -describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => { - it('seeds a declared set as a package-managed sys_permission_set row', async () => { - const ql = makeQl([declaredSet()]); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - const row = ql.rows[0]; - expect(row.name).toBe('crm_sales_rep'); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowCreate: true } }); - expect(JSON.parse(row.field_permissions)).toEqual({ 'crm_lead.amount': { readable: true, editable: false } }); - expect(JSON.parse(row.system_permissions)).toEqual(['crm.use']); - expect(row.active).toBe(true); - }); - - it('is idempotent + upgrade-aware: re-seeds its OWN row to the shipped declaration', async () => { - const ql = makeQl([declaredSet()]); - await bootstrapDeclaredPermissions(ql, undefined); - // simulate a package upgrade changing the shipped grants - (ql as any).registry = { - listItems: () => [declaredSet({ objects: { crm_lead: { allowRead: true } } })], - }; - const r2 = await bootstrapDeclaredPermissions(ql, undefined); - expect(r2.seeded).toBe(0); - expect(r2.updated).toBe(1); - expect(ql.rows.length).toBe(1); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('never clobbers env-authored rows (platform/user/legacy provenance)', async () => { - const ql = makeQl([declaredSet({ name: 'member_default' })]); - // pre-existing row WITHOUT provenance (legacy / bootstrapPlatformAdmin default) - ql.rows.push({ id: 'ps_legacy', name: 'member_default', object_permissions: '{"x":{"allowRead":true}}' }); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(0); - expect(r.updated).toBe(0); - expect(r.skippedEnvAuthored).toBe(1); - expect(ql.rows[0].object_permissions).toBe('{"x":{"allowRead":true}}'); - expect(ql.rows[0].managed_by).toBeUndefined(); - }); - - it('refuses to write into a row owned by a DIFFERENT package', async () => { - const ql = makeQl([declaredSet({ _packageId: 'com.example.other' })]); - ql.rows.push({ - id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm', - object_permissions: '{}', - }); - const warns: any[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m, meta) => warns.push({ m, meta }) }, - }); - expect(r.skippedForeign).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.crm'); - // [#17516] Re-anchored from the old prose ('owned by another package') to - // the stable token the report now stamps. The substance this pin asserts is - // unchanged — the refusal is reported — but the token is what an operator - // greps and what the sibling doors key on, so prose drift can no longer - // quietly unpin it. The read-back half is asserted beside it: a counter - // with no record is what made this drop invisible. - expect(warns.some((w) => String(w.m).includes('permission_set_name_collision'))).toBe(true); - expect(r.collisions).toEqual([ - expect.objectContaining({ name: 'crm_sales_rep', declaredBy: 'com.example.other', ownedBy: 'com.example.crm' }), - ]); - }); - - it('skips a declared set with no resolvable owning package (warned, not seeded)', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - expect(r.seeded).toBe(0); - expect(ql.rows.length).toBe(0); - expect(warns.some((w) => w.includes('no owning package'))).toBe(true); - }); - - it('falls back to the spec-declared packageId (ADR-0086 D3) when registry provenance is absent', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined, packageId: 'com.example.declared' })]); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.declared'); - }); -}); - -// ADR-0086 P2 块1 — the publish-time materializer shares this helper. Here the -// packageId is supplied explicitly (the draft's binding), not read off the body. -describe('upsertPackagePermissionSet (ADR-0086 P2 — publish materialization)', () => { - const publishedBody = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true } }, - ...over, - }); - - it('materializes a published set into a package-managed row under the draft packageId', async () => { - const ql = makeQl(); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.seeded).toBe(1); - expect(ql.rows[0].managed_by).toBe('package'); - expect(ql.rows[0].package_id).toBe('com.example.crm'); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('re-publish of its OWN row updates in place (idempotent)', async () => { - const ql = makeQl(); - await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - const r2 = await upsertPackagePermissionSet( - ql, publishedBody({ objects: { crm_lead: { allowRead: true, allowEdit: true } } }), 'com.example.crm', - ); - expect(r2.updated).toBe(1); - expect(ql.rows.length).toBe(1); - expect(JSON.parse(ql.rows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('refuses to clobber an env-authored row of the same name (two-doors: env door owns it)', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_env', name: 'crm_sales_rep', managed_by: 'user', object_permissions: '{"kept":true}' }); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.skippedEnvAuthored).toBe(1); - expect(r.seeded + r.updated).toBe(0); - expect(ql.rows[0].object_permissions).toBe('{"kept":true}'); - }); - - it('refuses a name owned by a DIFFERENT package', async () => { - const ql = makeQl(); - ql.rows.push({ id: 'ps_1', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.other', object_permissions: '{}' }); - const r = await upsertPackagePermissionSet(ql, publishedBody(), 'com.example.crm'); - expect(r.skippedForeign).toBe(1); - expect(ql.rows[0].package_id).toBe('com.example.other'); - }); - - it('skips (does not materialize) when the publish carries no packageId', async () => { - const ql = makeQl(); - const warns: string[] = []; - const r = await upsertPackagePermissionSet(ql, publishedBody(), null, { info: () => {}, warn: (m) => warns.push(m) }); - expect(r.seeded).toBe(0); - expect(ql.rows.length).toBe(0); - expect(warns.some((w) => w.includes('no owning package'))).toBe(true); - }); -}); - -// The environment door (env-scope saves, the data-door write-through, boot -// reconciliation) moved to permission-set-projection.test.ts (ADR-0094). - -// ─────────────────────────────────────────────────────────────────────────── -// [#18091] The two refusals #17516 left behind in this seeder. Measured mute on -// the pre-fix tree with no logger injected — author-visible lines = 0 at both, -// while the already-repaired collision path in the same harness read 1. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Capture EVERY console channel — "author-visible output" is not - * channel-specific, and a pin watching only `warn` could be satisfied by a - * change that merely MOVED the silence. - */ -function captureAllConsole() { - const seen: string[] = []; - const spies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((m) => - vi.spyOn(console, m).mockImplementation((...args: unknown[]) => { - seen.push(`${m}: ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`); - }), - ); - return { seen, restore: () => spies.forEach((s) => s.mockRestore()) }; -} - -/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */ -function unreadableQl(declared: any[]) { - const ql = makeQl(declared); - (ql as any).find = async () => { throw new Error('sys_permission_set is unreachable'); }; - return ql; -} - -describe('[#18091] the two remaining permission-set refusals reach the author', () => { - it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, and names the RECORD as what is lost', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - // ── The reading this card moves: 0 → 1 ────────────────────────────────── - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]!.startsWith('warn: ')).toBe(true); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(cap.seen[0]).toContain('crm_sales_rep'); - expect(cap.seen[0]).toContain('has no owning package'); - // ── THIS site's consequence. ⛔ NOT the capability axis': the evaluator - // resolves declared sets through the metadata registry, so every grant - // keeps working and only the RECORD is missing. An author told merely - // "not materialized" goes looking for a denied user who does not exist. - expect(cap.seen[0]).toContain('keep working'); - expect(cap.seen[0]).toContain('Setup admin surface'); - expect(cap.seen[0]).toContain('ADR-0086 D3'); - - // ── ⛔ The refusal itself is UNCHANGED ─────────────────────────────────── - expect(r.seeded).toBe(0); - expect(ql.rows).toHaveLength(0); - }); - - it('UNOWNED DECLARATION reaches the author through the ADR-0086 P2 PUBLISH door too', async () => { - // ⚠️ The second door onto the same branch: the publish materializer upserts - // ONE set and passes no collector, so a fix that only lit the boot loop - // would leave this caller exactly as mute as before. - const ql = makeQl(); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await upsertPackagePermissionSet(ql, declaredSet({ _packageId: undefined }), null); - } finally { - cap.restore(); - } - - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r.seeded).toBe(0); - expect(ql.rows).toHaveLength(0); - }); - - it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => { - const ql = unreadableQl([declaredSet(), declaredSet({ name: 'crm_manager' })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(r.unreadable).toBe(2); - const line = cap.seen.find((l) => l.includes(PERMISSION_SET_ROWS_UNREADABLE)); - expect(line).toBeDefined(); - expect(line!.startsWith('warn: ')).toBe(true); - expect(line).toContain('2 of 2'); - // Silence here reads exactly like "everything was already in order", so the - // line has to say what did NOT happen — and that no grant is denied by it. - expect(line).toContain('neither seeded nor reconciled'); - expect(line).toContain('no grant is'); - // ⛔ Unchanged: an unreadable read writes nothing. - expect(ql.rows).toHaveLength(0); - }); - - it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => { - const cap = captureAllConsole(); - try { - await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined); - await bootstrapDeclaredPermissions(unreadableQl([declaredSet()]), undefined); - } finally { - cap.restore(); - } - - const unowned = cap.seen.find((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED)); - const unreadable = cap.seen.find((l) => l.includes(PERMISSION_SET_ROWS_UNREADABLE)); - expect(unowned).toBeDefined(); - expect(unreadable).toBeDefined(); - // Each names a phrase only its own site can produce. - expect(unowned).toContain('Setup admin surface'); - expect(unowned).not.toContain('could not be read'); - expect(unreadable).toContain('could not be read'); - expect(unreadable).not.toContain('ADR-0086 D3'); - }); - - it('an INJECTED logger takes both, and the console stays clean', async () => { - const warn = vi.fn(); - const cap = captureAllConsole(); - try { - await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined, { logger: { warn } }); - await bootstrapDeclaredPermissions(unreadableQl([declaredSet()]), undefined, { logger: { warn } }); - } finally { - cap.restore(); - } - - // ⚠️ The second pass also trips the batched existence oracle's own - // read-failure line — a different diagnostic in `seed-name-lookup.ts`, - // outside this card — so filter by this card's tokens rather than counting. - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED, PERMISSION_SET_ROWS_UNREADABLE]); - expect(cap.seen).toEqual([]); - }); - - it('CONTROL: a pass that really seeds and refuses nothing is SILENT on all five channels', async () => { - // ⛔ The discriminating half. Without it, a seeder that warned on every - // declaration would satisfy every assertion above. - const ql = makeQl([declaredSet()]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(r.seeded).toBe(1); - expect(cap.seen).toEqual([]); - }); - - it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => { - // The old `logger?.warn?.()` bought safety against a plain-JS embedder with - // silence; a bare `logger.warn()` would buy noise with a throw inside the - // seeding pass. The `typeof` guard in `reportThroughSink` buys neither. - const liar = { info: () => {} } as any; - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(makeQl([declaredSet({ _packageId: undefined })]), undefined, { logger: liar }); - } finally { - cap.restore(); - } - expect(r.seeded).toBe(0); - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - }); - - it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => { - const source = readFileSync(resolve(HERE, 'bootstrap-declared-permissions.ts'), 'utf8'); - // Positive control — the pin is reading the file it thinks it is. - expect(source).toContain('export async function bootstrapDeclaredPermissions'); - const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.(')); - for (const line of hits) { - expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true); - } - // ⚠️ The INFO channel keeps its outer `?.` deliberately — a healthy pass - // must stay silent on every console channel, per the CONTROL above. - expect(source).toContain("options.logger?.info?.("); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#18571] The unowned refusal's PROGRAMMATIC half. #18564 gave this branch an -// author-visible line; the outcome still came back with every counter at zero, -// so a caller that reads no log at all could not tell a pass that refused a -// declaration from a pass with nothing to do. The capability axis has counted -// the same refusal at the same ADR-0086 D3 boundary since #4967. -// ─────────────────────────────────────────────────────────────────────────── - -/** Silent sink — these tests assert the COUNTER, and #18091 owns the line. */ -const mute = () => ({ info: () => {}, warn: () => {} }); - -describe('[#18571] the unowned permission-set refusal moves a counter', () => { - it('BOOT-LOOP DOOR: an unowned declaration increments skippedUnowned', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined })]); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(r.skippedUnowned).toBe(1); - // ⛔ The refusal itself is UNCHANGED — an unowned `managed_by:'package'` - // row is the ADR-0086 D3 ambiguity this branch exists to prevent. - expect(ql.rows).toHaveLength(0); - expect(r.seeded + r.updated + r.unchanged).toBe(0); - // ⛔ And it lands in its OWN counter, never borrowed from a sibling: an - // unowned declaration is not a foreign row and not an env-authored one. - expect(r.skippedForeign).toBe(0); - expect(r.skippedEnvAuthored).toBe(0); - expect(r.unreadable).toBe(0); - }); - - it('PUBLISH DOOR (ADR-0086 P2): the materializer increments it too', async () => { - // ⚠️ The second door onto the same branch, and the reason testing one is - // testing half: the publish materializer upserts ONE set and passes no - // collector, so a fix wired into the boot loop's aggregation alone would - // leave this caller's outcome exactly as silent as before. - const ql = makeQl(); - - const r = await upsertPackagePermissionSet(ql, declaredSet({ _packageId: undefined }), null, mute()); - - expect(r.skippedUnowned).toBe(1); - expect(ql.rows).toHaveLength(0); - expect(r.seeded).toBe(0); - }); - - it('CONTROL: a pass that refuses nothing leaves it at 0 through both doors', async () => { - // ⛔ The discriminating half. Without it, a counter incremented on every - // declaration would satisfy both assertions above. - const boot = await bootstrapDeclaredPermissions(makeQl([declaredSet()]), undefined, { logger: mute() }); - expect(boot).toMatchObject({ seeded: 1, skippedUnowned: 0 }); - - const publish = await upsertPackagePermissionSet(makeQl(), declaredSet(), 'com.example.crm', mute()); - expect(publish).toMatchObject({ seeded: 1, skippedUnowned: 0 }); - }); - - it('⭐ CONSERVATION: every named declaration lands in exactly one counter', async () => { - // Modelled on the capability axis' `materializedNames reconciles with the - // outcome counters`. The point is not the six numbers — it is that they - // SUM to the input, which is precisely what an uncounted refusal broke. - const body = (name: string, over: Record = {}) => - declaredSet({ name, _packageId: 'com.a', ...over }); - const sets = [ - body('crm_new'), // → seeded - body('crm_own', { label: 'Fresh' }), // → updated - body('crm_same'), // → unchanged - body('crm_env'), // → skippedEnvAuthored - body('crm_foreign'), // → skippedForeign - declaredSet({ name: 'crm_orphan', _packageId: undefined }), // → skippedUnowned - ]; - const ql = makeQl(sets); - // Its own row, stale — the stored label is not what the declaration says. - ql.rows.push({ - id: 'ps_own', name: 'crm_own', managed_by: 'package', package_id: 'com.a', - ...permissionSetRowFields(body('crm_own', { label: 'Stale' })), - }); - // Its own row, already converged — `recordDiffersFromBody` calls it equal, - // so no UPDATE is issued and the name lands in `unchanged`, not `updated`. - ql.rows.push({ - id: 'ps_same', name: 'crm_same', managed_by: 'package', package_id: 'com.a', - ...permissionSetRowFields(body('crm_same')), - }); - ql.rows.push({ id: 'ps_env', name: 'crm_env', managed_by: 'user', object_permissions: '{"kept":true}' }); - ql.rows.push({ id: 'ps_far', name: 'crm_foreign', managed_by: 'package', package_id: 'com.z', object_permissions: '{}' }); - - const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(out).toMatchObject({ - seeded: 1, updated: 1, unchanged: 1, - skippedEnvAuthored: 1, skippedForeign: 1, skippedUnowned: 1, - unreadable: 0, - }); - // ⚠️ The sum is taken over EVERY counter the outcome declares, so a future - // counter added without a home here fails this pin instead of hiding in - // a hand-picked subset — the failure mode `skippedUnowned` itself was. - const counted = out.seeded + out.updated + out.unchanged - + out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable; - expect(counted).toBe(sets.length); - // …and the refusal that wrote nothing really wrote nothing: five inputs - // reached a row, the sixth did not. - expect(ql.rows.map((r) => r.name).sort()).toEqual( - ['crm_env', 'crm_foreign', 'crm_new', 'crm_own', 'crm_same'], - ); - expect(ql.rows.some((r) => r.name === 'crm_orphan')).toBe(false); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#21669] An ENVIRONMENT-owned set in the registry walk is not an unowned -// declaration. The engine registry the boot loop walks also holds every -// env-wide `sys_metadata` `permission` row `loadMetaFromDb` hydrates — a set -// made by Setup's Clone action among them — and such an item carries no -// package id because it has none. The loop judged "unowned" before it read -// the row, so a set whose `managed_by:'admin'` row Setup lists and edits was -// reported, on every boot, as one Setup "cannot see". -// -// The item below is the shape measured on a real showcase cold boot after a -// Clone (the boot-level pin is -// `packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts`): -// no `_packageId`, no `packageId`, `_provenance: 'org'`. -// ─────────────────────────────────────────────────────────────────────────── - -const CLONE_NAME = 'crm_sales_rep_local'; - -/** The registry item a hydrated clone is — measured, not invented. */ -const clonedItem = () => ({ - name: CLONE_NAME, - label: 'Sales Rep (local)', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - fields: {}, - systemPermissions: [], - rowLevelSecurity: [], - tabPermissions: {}, - _provenance: 'org', -}); - -/** The row the Clone action leaves: the environment's own set. */ -const clonedRow = (over: Record = {}) => ({ - id: 'ps_clone', name: CLONE_NAME, managed_by: 'admin', package_id: null, active: true, - object_permissions: '{"crm_lead":{"allowRead":true,"allowCreate":true}}', - ...over, -}); - -/** The unowned refusal's first sentence — the text an operator reads, unchanged by this card. */ -const unownedFirstSentence = (name: string) => - `declared permission set "${name}" has no owning package — not materialized.`; - -describe('[#21669] an environment-owned set is told apart from an unowned declaration', () => { - it('a CLONED set (row managed_by admin) boots with no warning on any channel, and its row is untouched', async () => { - const ql = makeQl([clonedItem()]); - ql.rows.push(clonedRow()); - const before = JSON.stringify(ql.rows); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(cap.seen.filter((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED))).toEqual([]); - expect(cap.seen).toEqual([]); - // Where a package declaration over an environment row already lands — - // never clobbered, and counted, so CONSERVATION still holds. - expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0, updated: 0, unchanged: 0 }); - expect(JSON.stringify(ql.rows)).toBe(before); - }); - - it('the same, on a per-organization pass: the environment door\'s organization-less row is the environment\'s', async () => { - // `permission-set-projection.ts` (the ENVIRONMENT door) is deliberately not - // per-organization, so a clone's row carries no organization_id and a - // walled pass sees it only beside its own rows. - const ql = makeQl([clonedItem()]); - ql.rows.push(clonedRow()); - - const warn = vi.fn(); - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [], - }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).not.toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0 }); - expect(ql.rows).toHaveLength(1); - }); - - it('⛔ a TRULY unowned declaration — no package id, no row — still warns, with the same code and text', async () => { - const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]); - - const cap = captureAllConsole(); - let r: Awaited>; - try { - r = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(`[${PERMISSION_SET_DECLARATION_UNOWNED}]`); - expect(cap.seen[0]).toContain(unownedFirstSentence('crm_orphan')); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - expect(ql.rows).toHaveLength(0); - }); - - it('⛔ the same on a per-organization pass with no row at all', async () => { - const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]); - const warn = vi.fn(); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [], - }); - - const unowned = warn.mock.calls.filter((c) => (c[1] as any)?.event === PERMISSION_SET_DECLARATION_UNOWNED); - expect(unowned).toHaveLength(1); - expect(String(unowned[0]![0])).toContain(unownedFirstSentence('crm_orphan')); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⛔ a row the PACKAGE door owns is not the environment\'s: an unowned item over it still warns', async () => { - // The guard asks who owns the ROW. A `managed_by:'package'` row under the - // name is a package's record, so it proves nothing about an item that - // carries no owner of its own. - const ql = makeQl([declaredSet({ _packageId: undefined })]); - ql.rows.push({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' }); - - const warn = vi.fn(); - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED]); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⛔ a read that cannot answer proves nothing: the unowned refusal stands, exactly as before', async () => { - const ql = unreadableQl([clonedItem()]); - const warn = vi.fn(); - - const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } }); - - const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean); - expect(events).toContain(PERMISSION_SET_DECLARATION_UNOWNED); - expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 }); - }); - - it('⭐ CONSERVATION, with a clone in the walk: every named item lands in exactly one counter', async () => { - const owned = declaredSet({ name: 'crm_new', _packageId: 'com.a' }); - const orphan = declaredSet({ name: 'crm_orphan', _packageId: undefined }); - const ql = makeQl([owned, clonedItem(), orphan]); - ql.rows.push(clonedRow()); - - const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() }); - - expect(out).toMatchObject({ seeded: 1, skippedEnvAuthored: 1, skippedUnowned: 1, unreadable: 0 }); - const counted = out.seeded + out.updated + out.unchanged - + out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable; - expect(counted).toBe(3); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts deleted file mode 100644 index da70bca61e6..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredPermissions — seed stack-declared `permissions` into - * `sys_permission_set` (ADR-0086 D5; the exact sibling of - * `bootstrapDeclaredPositions`). - * - * `stack.permissions` has always been declarable and runtime-ENFORCED (the - * evaluator resolves declared sets through the metadata registry), but it was - * never materialized as `sys_permission_set` records — the ADR-0078 - * inert-metadata smell: the admin surface (which reads the table) can't see a - * package's sets, uninstall is undefined, and no provenance axis exists. This - * seeder closes that gap: - * - * - each declared set is upserted by `(name, organization_id)` with `managed_by: 'package'` and - * `package_id` = the registering package (`_packageId` stamped by the - * SchemaRegistry / ADR-0010 `applyProtection`, with the spec-level - * `packageId` (ADR-0086 D3) as the author-declared fallback); - * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns - * (`managed_by:'package'`, same `package_id`) is re-seeded on every boot so - * the record always reflects the shipped declaration (version bumps - * included). Rows owned by a DIFFERENT package are skipped loudly; - * - env-authored rows are NEVER clobbered: `managed_by` of - * `platform`/`user` — or absent (legacy/pre-provenance rows, including the - * platform defaults inserted by `bootstrapPlatformAdmin`) — is left alone. - * - * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform - * defaults keep their existing insert-once shape) and alongside - * `bootstrapDeclaredPositions`. - * - * The ENVIRONMENT door (env-scope metadata saves, the Setup data-door - * write-through, boot reconciliation) lives in `permission-set-projection.ts` - * (ADR-0094) — this module is the PACKAGE door only. Both project through the - * shared {@link permissionSetRowFields} row shape so they can never hydrate - * differently. - * - * [#10103] The PACKAGE door runs ONE PASS PER ORGANIZATION when a wall is in - * force: `sys_permission_set` spells its name index `unique: 'organization'`, - * and an organization-less row was measured unreadable by every principal on a - * walled deployment — Layer 0's strict `organization_id = :tenant` AND-composes - * over the driver's compatibility arm and the conjunction is the strict equality - * alone. `single` posture keeps exactly one organization-less pass. The - * ENVIRONMENT door is deliberately NOT converted: its organization-less residue - * (and `bootstrapPlatformAdmin`'s platform defaults) stays outside this change, - * unreaped and warned about loudly. See `per-organization-catalog.ts`. - */ - -import { - genId, - permissionSetRowFields, - recordDiffersFromBody, - tryInsert, - tryUpdate, - type PermissionSeedOutcome, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - buildExistingByName, - type ExistingByNameIndex, - type ExistingLookupResult, -} from './seed-name-lookup.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { - resolveOwnOrganizationRow, - seedCtx, - warnOrganizationLessRows, -} from './organization-scope.js'; -import { - permissionSetNameCollisionDiagnostic, - permissionSetNameIsForeign, - reportPermissionSetNameCollisions, - type PermissionSetNameCollisionDiagnostic, -} from './permission-set-name-collision.js'; -// [#18091] This seeder's two remaining refusals, each with its own wording, -// token and record, over the one shared delivery rule. -import { - permissionSetDeclarationUnownedDiagnostic, - reportPermissionSetDeclarationUnowned, - reportPermissionSetRowsUnreadable, -} from './seed-refusal-diagnostics.js'; - -export type { PermissionSeedOutcome } from './permission-set-projection.js'; - -/** - * The per-name existence read used when no batched oracle was supplied (the - * ADR-0086 P2 publish materializer, which upserts exactly one set). Reports the - * same three outcomes the batched oracle does — a failed read must not read as - * "absent" on this path either. - */ -async function defaultLookup(ql: any, name: string, organizationId?: string): Promise { - let rows: any; - try { - // Limit 5, not 1, when scoped: a scoped read returns organization-less rows - // alongside this organization's own, and one row would be whichever the - // driver ordered first (#10103). - rows = await ql.find( - 'sys_permission_set', - { where: { name }, limit: organizationId ? 5 : 1 }, - { context: seedCtx(organizationId) }, - ); - } catch { - return { status: 'unknown' }; - } - // Bare array, driven against a real engine over a real `SqlDriver` — not - // inferred from `IDataEngine.find`'s declared `Promise`, which is not - // proof (this repo has a `find()` that resolves an envelope instead). The - // `{ records }` limb that stood here was dead; `engine-find-bare-array.pin.test.ts` - // pins this seam. A non-array is still `unknown` — never "no such row". - const list = Array.isArray(rows) ? rows : null; - if (list === null) return { status: 'unknown' }; - // [#10103] This organization's own row answers; an organization-less leftover - // is reported beside `absent` and never returned as `present`. One spelling of - // that question for the whole catalog — see `per-organization-catalog.ts`. - const { own, organizationLessResidue } = resolveOwnOrganizationRow(list, organizationId); - if (own) return { status: 'present', row: own }; - return organizationLessResidue - ? { status: 'absent', organizationLessResidue } - : { status: 'absent' }; -} - -interface SeedOptions { - logger?: ProjectionLogger; - /** - * Seed THIS organization's copies. Omitted = the `single`-posture pass, the - * one place an organization-less catalog row is the correct shape. - */ - organizationId?: string; - /** - * [#11532] Names that `bootstrapPlatformAdmin` still seeds organization-less - * on every boot — the PLATFORM BUCKET. Supplied by the caller because the - * caller is the one holding that array (`security-plugin.ts` hands the same - * `bootstrapPermissionSets` to the platform bootstrap and to the manifest), - * so the list is exact rather than inferred from a row's provenance. - * - * An organization-less row for one of these names is NOT a pre-fix leftover - * and the pre-fix remedy is a loop for it: re-initializing the deployment - * mints it again on the next boot. See {@link warnOrganizationLessRows}. - * - * Omitted falls back to the SHIPPED `defaultPermissionSets` — which is what - * `bootstrapPlatformAdmin` seeds unless the host passed - * `SecurityPluginOptions.defaultPermissionSets`. So the classification is - * right for every shipped composition even if this option is never threaded, - * and the option exists for the one case the fallback cannot know about: a - * host that overrode the array. Pass `[]` to state that this caller's - * deployment has no organization-less writer at all. - */ - platformBucketNames?: readonly string[]; -} - -/** - * Read declared metadata items of a type. The engine's SchemaRegistry - * (populated by `manifest.register` from the stack's `permissions` array, - * items provenance-stamped with `_packageId`) is the reliable source in every - * boot path; the metadata-service facade only surfaces these once the - * compiled-artifact loader runs (serve.ts). - * - * [#8378] The registered item IS the authoring document — there is no - * `{ name, content }` envelope to unwrap, so the `i?.content ?? i` this read - * used to carry is gone. Same measurement #7519 made for `MetadataFacade`, - * re-taken at this seam: `registerMetadataCollections` (objectql `engine.ts`) - * registers each stack-collection element as-is, and `loadMetaFromDb` registers - * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never the - * `sys_metadata` row. Nothing in the tree produces the envelope. - * - * Removal is a FIX, not a tidy-up. The types read through here (`permission`, - * `capability`, `object`) all reject `content` as an unrecognized key, so - * whenever the key did appear the unwrap replaced a whole authoring document - * with one of its values — and `''`, falsy but non-nullish, passed `??` and - * then died at the `filter(Boolean)` below, dropping the item with no - * diagnostic at all. - */ -export function readDeclared(engine: any, type: string): any[] { - try { - const reg = engine?.registry; - if (reg?.listItems) { - return (reg.listItems(type) ?? []).filter(Boolean); - } - } catch { /* fall through */ } - return []; -} - -/** - * ADR-0086 two-doors — is this `sys_permission_set` row ENVIRONMENT-owned? - * - * The one spelling of the fact this seeder already acted on: a row the PACKAGE - * door does not own — `managed_by` `admin`/`platform`, the legacy `user`, or - * absent (pre-provenance rows, including `bootstrapPlatformAdmin`'s platform - * defaults) — is environment-authored config that package materialization - * never clobbers. {@link upsertPackagePermissionSet}'s last branch and the boot - * loop's unowned-vs-environment split both ask THIS predicate, so the two can - * never disagree about which rows are the environment's. - */ -function isEnvironmentOwnedRow(row: any): boolean { - return !!row && typeof row === 'object' && row.managed_by !== 'package'; -} - -/** - * [#21669] Does the environment already own a row under this name? - * - * The engine registry the boot loop walks holds MORE than package - * declarations: `loadMetaFromDb` hydrates every env-wide `sys_metadata` - * `permission` row into the same collection, and a set an environment - * authored itself — the Setup Clone action, a Setup "New", a metadata-door - * save — carries no package id there, because none exists. Read as a - * declaration, such an item is "unowned"; it is not a declaration at all. Its - * row stands, the environment door owns it, and Setup lists it. - * - * Answered from the batched oracle the loop already holds — no extra read. - * The row is this pass's own, or — on a per-organization pass — the - * organization-less row the ENVIRONMENT door writes (`permission-set- - * projection.ts` is deliberately not per-organization; see this file's - * header). `unknown` is NOT an answer: a read that failed cannot prove the - * environment owns the name, so the caller falls through to the unowned - * refusal exactly as before. - */ -async function environmentOwnsName(existingByName: ExistingByNameIndex, name: string): Promise { - const lookup = await existingByName.get(name); - const row = lookup.status === 'present' - ? lookup.row - : lookup.status === 'absent' ? lookup.organizationLessResidue : undefined; - return isEnvironmentOwnedRow(row); -} - -/** - * Upsert ONE declared/published PermissionSet body into `sys_permission_set` - * under the owning `packageId`, applying the ADR-0086 provenance rules - * (own-row re-seed, foreign-package refuse, env-authored never clobbered). - * Shared by the boot seeder (every declared set) and the publish-time - * materializer (ADR-0086 P2 — a package-door set promoted from a draft). Returns - * a one-hot outcome so callers can aggregate. - */ -export async function upsertPackagePermissionSet( - ql: any, - ps: any, - packageId: string | null | undefined, - logger?: SeedOptions['logger'], - opts?: { - /** - * Existence oracle to consult instead of this function's own per-name - * `SELECT` (#10946). The boot loop passes ONE batched read covering every - * declared name; the publish materializer, which upserts a single set, - * passes nothing and keeps the per-name read. - */ - existingByName?: ExistingByNameIndex; - /** - * Seed THIS organization's copy (#10103). Omitted = the pre-existing - * organization-less behaviour, which the `single`-posture pass and the - * ADR-0086 P2 publish materializer both want. - */ - organizationId?: string; - /** Collects names whose pre-fix organization-less row is still standing. */ - residue?: string[]; - /** - * Collects writes the database REFUSED, so the pass can report them once - * instead of returning a zero that reads as "nothing to do". Passed by the - * boot catalog loop; the ADR-0086 P2 publish materializer passes nothing - * and keeps its own honest outcome (`success: false` with a reason when it - * materialized nothing). - */ - refusals?: SeedWriteRefusals; - /** - * [#17516] Collects set-name collisions so the pass reports them ONCE - * instead of a line per dropped set. Passed by the boot catalog loop; the - * ADR-0086 P2 publish materializer passes nothing and the refusal is - * reported at the branch instead — ⛔ never dropped, which is the whole - * point of this card. - */ - collisions?: PermissionSetNameCollisionDiagnostic[]; - }, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ps?.name) return out; - // A `managed_by:'package'` row without a `package_id` would make uninstall - // undefined again — the exact ambiguity ADR-0086 D3 exists to remove — so a - // set with no resolvable owner is skipped rather than materialized unowned. - if (!packageId) { - out.skippedUnowned += 1; - // [#18091] ⛔ The refusal is unchanged — an unowned row would re-create the - // ADR-0086 D3 ambiguity. What changed is that it arrives. Measured on the - // pre-fix tree with no logger, through BOTH doors that reach this branch - // (the boot loop and the ADR-0086 P2 publish materializer, which passes no - // collector): author-visible console lines = 0. - // - // [#18571] And a counter, because the LINE is not the programmatic half: a - // caller that reads no log at all — a boot report, a test — asked the - // outcome and got six zeros back, indistinguishable from a pass with - // nothing to do. Both doors above increment it; the boot loop forwards it. - // - // Its own wording, because the consequence is not the capability axis': - // the declared set stays runtime-enforced and only the RECORD is missing. - reportPermissionSetDeclarationUnowned(logger, permissionSetDeclarationUnownedDiagnostic({ - name: String(ps.name), - ...(opts?.organizationId ? { organizationId: opts.organizationId } : {}), - })); - return out; - } - - // ⛔ Three outcomes, not two (#10946 / #3807). `unknown` — the read FAILED — - // is not "no such row": inserting on it would re-create a set that already - // exists, and on a batched read one failure speaks for every declared name at - // once. Declining is the answer; the caller's warn reports it. - const organizationId = opts?.organizationId; - const lookup = opts?.existingByName - ? await opts.existingByName.get(String(ps.name)) - : await defaultLookup(ql, String(ps.name), organizationId); - if (lookup.status === 'unknown') { - out.unreadable += 1; - return out; - } - // [#10103] `absent` can still carry a PRE-FIX organization-less row that is - // merely VISIBLE here through the driver's compatibility arm. It is not this - // organization's row, so the copy below is created either way — the leftover - // is only reported. Reading it as "already seeded" is the silent no-op the - // per-organization catalog exists to prevent. - if (lookup.status === 'absent' && lookup.organizationLessResidue && ps.name) { - opts?.residue?.push(String(ps.name)); - } - const existing = lookup.status === 'present' ? lookup.row : undefined; - if (!existing?.id) { - const row = { - id: genId('ps'), - name: ps.name, - ...permissionSetRowFields(ps), - active: true, - package_id: packageId, - managed_by: 'package', - }; - const created = await tryInsert(ql, 'sys_permission_set', row, organizationId, opts?.refusals); - if (created) { - out.seeded += 1; - // A batched oracle is a snapshot taken before the loop — tell it about - // the row we just made, so a name declared twice in one batch still - // reaches the collision branch below instead of a second insert. - opts?.existingByName?.remember(String(ps.name), row); - } - return out; - } - - if (!isEnvironmentOwnedRow(existing)) { - if (!permissionSetNameIsForeign(existing.package_id, packageId)) { - // Our own row — re-seed so the record always reflects the shipped/published - // declaration (idempotent; covers version bumps without bookkeeping). - // - // [#10946] "Idempotent" was implemented as "write the same columns every - // time", which on a remote libsql/Turso database is two HTTP round trips - // per set on every boot to change nothing. `recordDiffersFromBody` is the - // SAME comparison the ADR-0094 boot reconciler already trusts to decide - // whether a record drifted, over exactly the columns - // `permissionSetRowFields` writes — so a row it calls equal is a row this - // UPDATE could not have changed. - // - // ⚠️ The skip is on EQUALITY, never on "we have seen this name". A row - // whose stored value differs — a version bump, a hand-edit, a partially - // applied write — still gets its UPDATE, because dropping that leg would - // turn the loop into a no-op that reconciles nothing while showing a - // beautiful round-trip curve. - if (!recordDiffersFromBody(existing, ps)) { - out.unchanged += 1; - } else if (await tryUpdate(ql, 'sys_permission_set', { id: existing.id, ...permissionSetRowFields(ps) }, organizationId, opts?.refusals)) { - out.updated += 1; - } - } else { - // [#17516] The SKIP is unchanged and correct — ADR-0086 D4: a package - // never writes into a foreign record. What changed is that it is no - // longer invisible. - // - // ⚠️ The premise this branch used to state — "Package-namespaced object - // api names make set-name collisions a packaging bug, not a merge case" - // — is FALSIFIED by ADR-0130 D1, which lets N packages co-own one - // namespace (the ADR records it under "What was NOT decided"). A - // collision is therefore a legal configuration that gets MORE common as - // co-ownership lands, not a packaging error that should never happen. So - // the author reading it is the normal case, not the pathological one. - // - // ⛔ And the old line did not refuse loudly, whatever it claimed: - // `logger?.warn?.(…)` is optionally chained TWICE, so a caller passing no - // logger produced NO OUTPUT AT ALL and an entire declared permission set - // disappeared with one counter moved. The report now goes through - // `reportPermissionSetNameCollisions`, which prints with no sink injected - // (#10556: silent-by-declaration is rejected), and the diagnostic RECORD - // travels back on the outcome so a caller that reads no log at all — a - // boot report, a test — can still ask what happened. - out.skippedForeign += 1; - const diagnostic = permissionSetNameCollisionDiagnostic({ - name: String(ps.name), - declaredBy: packageId, - ownedBy: typeof existing.package_id === 'string' ? existing.package_id : null, - ...(organizationId ? { organizationId } : {}), - }); - out.collisions = [diagnostic]; - // The boot loop collects and reports ONCE per pass. The ADR-0086 P2 - // publish materializer upserts a single set and passes no collector, so - // it reports here — it has no pass to summarise, and inheriting the old - // silence is the one outcome this card forbids. - if (opts?.collisions) opts.collisions.push(diagnostic); - else reportPermissionSetNameCollisions(logger, [diagnostic], organizationId); - } - return out; - } - - // {@link isEnvironmentOwnedRow}: `admin`/`platform`/`user` — or absent - // (legacy rows, incl. bootstrapPlatformAdmin defaults): env-authored config. - // Never clobbered by package materialization. - out.skippedEnvAuthored += 1; - return out; -} - -/** - * [#11532] The names the SHIPPED `bootstrapPlatformAdmin` seeds - * organization-less on every boot. Computed once from the same declaration the - * platform bootstrap iterates, so the two cannot drift within a release. - */ -const SHIPPED_PLATFORM_BUCKET_NAMES: readonly string[] = defaultPermissionSets - .map((ps) => ps.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - -export async function bootstrapDeclaredPermissions( - ql: any, - metadataService: any, - options: SeedOptions = {}, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; - - let sets: any[] = readDeclared(ql, 'permission'); - if (sets.length === 0) { - try { - const listed = metadataService?.list?.('permission'); - sets = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { sets = []; } - } - if (!Array.isArray(sets) || sets.length === 0) return out; - - // [#10946] ONE existence read for the whole declaration, before the loop — - // the set of names is known in full here. See `seed-name-lookup.ts` for why - // a read that cannot ANSWER must not be read as "none of them exist". - const organizationId = options.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - sets.map((ps) => ps?.name), - options.logger, - organizationId, - ); - // Names for which a PRE-FIX organization-less row is still standing. This - // organization's own row is created regardless — the leftover is reported, - // never treated as "already seeded" (#10103). - const residue: string[] = []; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY declared permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#17516] Declared sets dropped because another package owns the name. - const collisions: PermissionSetNameCollisionDiagnostic[] = []; - - for (const ps of sets) { - if (!ps?.name) continue; - // Registry provenance first (ADR-0010 `_packageId`), author-declared - // spec `packageId` (ADR-0086 D3) as fallback. - const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined; - // [#21669] Tell an ENVIRONMENT-owned set apart from a package declaration - // BEFORE judging ownership. A registry item with no package id whose row - // the environment already owns (a Setup clone: `managed_by:'admin'`, no - // `package_id`) is not an unowned declaration — it is not a declaration — - // and the `permission_set_declaration_unowned` warning told the operator, - // on every boot and every `metadata:reloaded`, that Setup could not see a - // set Setup lists and edits. It lands where every package declaration - // over an environment row lands: `skippedEnvAuthored`, never clobbered. - // ⛔ A declaration with no owner AND no environment row still reaches the - // unowned refusal below, unchanged — that warning is true. - if (!packageId && await environmentOwnsName(existingByName, String(ps.name))) { - out.skippedEnvAuthored += 1; - continue; - } - const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals, collisions }); - out.seeded += r.seeded; - out.updated += r.updated; - out.unchanged += r.unchanged; - out.unreadable += r.unreadable; - out.skippedEnvAuthored += r.skippedEnvAuthored; - out.skippedForeign += r.skippedForeign; - out.skippedUnowned += r.skippedUnowned; - } - - if (organizationId) { - warnOrganizationLessRows( - options.logger, - 'sys_permission_set', - residue, - organizationId, - options.platformBucketNames ?? SHIPPED_PLATFORM_BUCKET_NAMES, - ); - } - // Before the counts, so an operator reads WHY the count is zero beside it. - reportSeedWriteRefusals(options.logger, refusals, organizationId); - // [#17516] Said once per pass, and said even when no logger was injected — - // the whole defect was that this refusal reached nobody. The records go back - // on the outcome too, for a caller that reads no log at all. - reportPermissionSetNameCollisions(options.logger, collisions, organizationId); - if (collisions.length > 0) out.collisions = collisions; - if (out.unreadable > 0) { - // Said once, with the count: these sets were neither seeded nor reconciled - // because the record could not be READ. Silence here would read exactly - // like "everything was already in order". - // [#18091] …and said even when no logger was injected: silence here reads - // exactly like "everything was already in order", which is the whole defect. - reportPermissionSetRowsUnreadable(options.logger, { - unreadable: out.unreadable, - total: sets.length, - ...(organizationId ? { organizationId } : {}), - }); - } - - options.logger?.info?.('[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)', { - ...out, total: sets.length, ...(organizationId ? { organization: organizationId } : {}), - }); - return out; -} diff --git a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts deleted file mode 100644 index 30d131fc92b..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#10946] The identity boot seeders cost O(1) database round trips on a - * steady-state rebuild — and still reconcile. - * - * ## What is measured here, and what is NOT - * - * The defect this file pins is a **COUNT**, not a latency: every declared - * permission set and every declared position cost exactly 4 sequential database - * round trips on every kernel boot (2 × existence `SELECT`, 1 × `UPDATE`, - * 1 × `SELECT`), of which the `UPDATE` fired even when nothing had changed. - * - * On a local file database that loop is invisible; on a remote libsql/Turso - * database — every hosted environment — each leg is its own sequential HTTP - * request. - * - * A count is measurable without the hosted rig, so that is what these tests - * measure: every `find` / `insert` / `update` the seeder issues against the - * ObjectQL facade is one round trip, counted by {@link makeCountingQl}. - * - * ⚠️ The card's LATENCY figure (the whole `bootstrap` step growing 171.7 ms per - * ms of injected RTT, R² = 0.998) is **inherited from the hosted rig in - * `objectstack-ai/cloud`, not reproduced here** — nothing in this file measures - * wall time, and a test that did would measure the machine it ran on. - * - * ## Why the assertions are shaped the way they are - * - * A round-trip suite alone is a trap: an implementation that simply stopped - * writing would produce a perfect curve and silently stop reconciling — the - * loops would keep their shape and lose their purpose. So the counting tests - * are paired, one for one, with reconciliation tests over the same fixtures: - * a drifted row still gets its `UPDATE`, an absent name is still created, and a - * read that FAILED is never mistaken for a read that answered "none". - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; - -/** - * The engine registry as the security catalog read uses one — its list, its - * by-name read and its disabled-package question — over a fixed list. - */ -function catalogRegistry(type: string, items: () => any[]) { - return { - listItems: (t: string) => (t === type ? [...items()] : []), - getItem: (t: string, name: string) => (t === type ? items().find((i) => i?.name === name) : undefined), - isPackageDisabled: () => false, - }; -} - -interface CountingQl { - rows: any[]; - calls: { find: number; insert: number; update: number }; - /** Every round trip in issue order — `find`/`insert`/`update`. */ - log: string[]; - /** Payloads of the `where` clauses the seeder issued, for shape assertions. */ - wheres: any[]; - roundTrips(): number; - reset(): void; - registry: ReturnType; - find(object: string, q: any, opts?: any): Promise; - insert(object: string, data: any, opts?: any): Promise; - update(object: string, data: any, options?: any): Promise; -} - -/** - * An in-memory ObjectQL facade that COUNTS calls. Supports the `$in` membership - * operator, because the real engine does (`security-plugin.ts` already reads - * `sys_permission_set` with `{ name: { $in: names } }`) — a double that refused - * it would be pinning the double's limits, not the seeder's behaviour. - */ -function makeCountingQl( - object: string, - metadataType: string, - declared: any[], - behaviour: { findThrows?: boolean; findReturnsNonArray?: boolean } = {}, -): CountingQl { - const rows: any[] = []; - const matches = (row: any, where: any): boolean => - Object.entries(where ?? {}).every(([key, cond]) => { - // REFUSE the combinators this double does not implement rather than - // reading `$and`/`$or` as a column name — a matcher that silently treats - // a combinator as a field is how a fake quietly answers a question the - // real engine would have answered differently. - if (key.startsWith('$')) { - throw new Error(`counting driver: unsupported combinator ${key}`); - } - // [commit c33f18592] A `null` comparand is IS NULL, not `=== null`. `driver-sql` - // compiles `{ field: null }` to `IS NULL`; a column never written is NULL - // in the database and `undefined` in this double, and strict equality - // matches neither. Inert for every describe above — none of them issues a - // null comparand — and required by the curated capability read, which is - // predicated on `organization_id: null`. - if (cond === null) return row[key] == null; - if (cond && typeof cond === 'object' && !Array.isArray(cond)) { - const inList = (cond as any).$in; - if (Array.isArray(inList)) return inList.includes(row[key]); - throw new Error(`counting driver: unsupported operator ${Object.keys(cond).join(',')}`); - } - return row[key] === cond; - }); - - const ql: CountingQl = { - rows, - calls: { find: 0, insert: 0, update: 0 }, - log: [], - wheres: [], - roundTrips() { return this.calls.find + this.calls.insert + this.calls.update; }, - reset() { this.calls = { find: 0, insert: 0, update: 0 }; this.log = []; this.wheres = []; }, - registry: catalogRegistry(metadataType, () => declared), - async find(obj: string, q: any) { - if (obj !== object) return []; - ql.calls.find += 1; - ql.log.push('find'); - ql.wheres.push(q?.where); - if (behaviour.findThrows) throw new Error('counting driver: read unavailable'); - if (behaviour.findReturnsNonArray) return undefined as any; - return rows.filter((r) => matches(r, q?.where)); - }, - async insert(obj: string, data: any) { - if (obj !== object) return null; - ql.calls.insert += 1; - ql.log.push('insert'); - rows.push({ ...data }); - return { id: data.id }; - }, - // Routed through the real dispatch predicate: a fake looser than - // ObjectQL.update would let the seeder drift to a call shape the engine - // refuses while this suite stayed green. - async update(obj: string, data: any, options?: any) { - if (obj !== object) return; - ql.calls.update += 1; - ql.log.push('update'); - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? rows.filter((r) => r.id === dispatch.id) - : rows.filter((r) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; - return ql; -} - -/** - * `objects` is typed rather than inferred: the upgrade fixtures below widen a - * grant (`{ allowRead }` -> `{ allowRead, allowEdit }`) to simulate a package - * version bump, which an inferred literal type rejects. - */ -interface DeclaredSet { - name: string; - label: string; - objects: Record>; - systemPermissions: string[]; - _packageId: string; -} - -const declaredSets = (n: number): DeclaredSet[] => - Array.from({ length: n }, (_, i) => ({ - name: `pkg_set_${i}`, - label: `Set ${i}`, - objects: { crm_lead: { allowRead: true } }, - systemPermissions: [`crm.use.${i}`], - _packageId: 'com.example.crm', - })); - -const permissionQl = (declared: any[], behaviour = {}) => - makeCountingQl('sys_permission_set', 'permission', declared, behaviour); - -describe('#10946 — steady-state rebuild is O(1) round trips (permission sets)', () => { - it('does not grow the rebuild round-trip count with the number of declared sets', async () => { - const measure = async (n: number) => { - const ql = permissionQl(declaredSets(n)); - await bootstrapDeclaredPermissions(ql, undefined); // first boot: seeds - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); // REBUILD - expect(r.seeded).toBe(0); - expect(r.updated).toBe(0); - expect(r.unchanged).toBe(n); - return ql.roundTrips(); - }; - - const [n1, n5, n20, n40] = [await measure(1), await measure(5), await measure(20), await measure(40)]; - // The count is asserted, never the wall time. - expect([n1, n5, n20, n40]).toEqual([1, 1, 1, 1]); - }); - - it('issues ONE batched `$in` existence read for the whole declaration', async () => { - const ql = permissionQl(declaredSets(12)); - await bootstrapDeclaredPermissions(ql, undefined); - ql.reset(); - await bootstrapDeclaredPermissions(ql, undefined); - expect(ql.calls.find).toBe(1); - expect(ql.wheres[0]).toEqual({ name: { $in: declaredSets(12).map((s) => s.name) } }); - }); - - it('first boot costs one batched read plus one INSERT per genuinely new set', async () => { - const ql = permissionQl(declaredSets(10)); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(10); - expect(ql.calls.find).toBe(1); - expect(ql.calls.insert).toBe(10); - expect(ql.calls.update).toBe(0); - expect(ql.rows).toHaveLength(10); - }); -}); - -/** - * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write - * would pass every count above while reconciling nothing at all. - */ -describe('#10946 — drift STILL reconciles', () => { - it('a permission-set row whose stored grants differ still gets its UPDATE', async () => { - const ql = permissionQl(declaredSets(20)); - await bootstrapDeclaredPermissions(ql, undefined); - - // The package ships a changed declaration for exactly ONE of the 20. - const upgraded = declaredSets(20); - upgraded[7] = { ...upgraded[7], objects: { crm_lead: { allowRead: true, allowEdit: true } } }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? upgraded : []) }; - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.updated).toBe(1); - expect(r.unchanged).toBe(19); - expect(ql.calls.update).toBe(1); - const row = ql.rows.find((x) => x.name === 'pkg_set_7'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('a permission-set row a hand-edit drifted is healed back to the declaration', async () => { - const ql = permissionQl(declaredSets(3)); - await bootstrapDeclaredPermissions(ql, undefined); - // Someone wrote straight at the row. - ql.rows[1].object_permissions = JSON.stringify({ crm_lead: { allowDelete: true } }); - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.updated).toBe(1); - expect(JSON.parse(ql.rows[1].object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); -}); - -describe('#10946 — a genuinely NEW declaration is still created', () => { - it('the batched read does not turn "absent" into "present" (permission sets)', async () => { - const ql = permissionQl(declaredSets(5)); - await bootstrapDeclaredPermissions(ql, undefined); - - const grown = [...declaredSets(5), { - name: 'pkg_set_new', label: 'New', objects: {}, _packageId: 'com.example.crm', - }]; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? grown : []) }; - - ql.reset(); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(1); - expect(r.unchanged).toBe(5); - expect(ql.rows.map((x) => x.name)).toContain('pkg_set_new'); - // one batched read + one insert — the other five cost nothing at all - expect(ql.roundTrips()).toBe(2); - }); -}); - -/** - * ⛔ #3807's conflation class, at the seam a batched read newly exposes. The - * per-item shape was accidentally immune: a failed read fell through to an - * insert that failed too, for that ONE item. A batched read that swallowed its - * failure into `[]` would speak for the WHOLE set — every boot would conclude - * nothing is seeded and try to re-create everything. - * - * The judgement is "did the driver return a result set", never "is the array - * empty": an empty array is the answer "none of these names exist", and the - * first-boot tests above depend on that answer being trusted. - */ -describe('#10946 — a read that CANNOT ANSWER is not the answer "none exist"', () => { - it('a throwing read does NOT re-create rows that are already seeded (permission sets)', async () => { - const ql = permissionQl(declaredSets(4)); - await bootstrapDeclaredPermissions(ql, undefined); - expect(ql.rows).toHaveLength(4); - - // Every read now fails — the batched one and the per-item fallback alike. - const broken = permissionQl(declaredSets(4), { findThrows: true }); - broken.rows.push(...ql.rows.map((r) => ({ ...r }))); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(broken, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - - expect(r.seeded).toBe(0); - expect(r.unreadable).toBe(4); - expect(broken.calls.insert).toBe(0); // ⛔ no blind insert - expect(broken.rows).toHaveLength(4); // ⛔ nothing re-created - expect(warns.some((w) => w.includes('batched seed existence read failed'))).toBe(true); - expect(warns.some((w) => w.includes('could not be read'))).toBe(true); - }); - - it('a read returning a non-result (undefined) is not read as "none exist"', async () => { - const ql = permissionQl(declaredSets(4), { findReturnsNonArray: true }); - ql.rows.push(...declaredSets(4).map((s, i) => ({ - id: `ps_${i}`, name: s.name, managed_by: 'package', package_id: 'com.example.crm', - label: s.label, description: null, - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', - row_level_security: '[]', tab_permissions: '{}', admin_scope: null, - }))); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(0); - expect(r.unreadable).toBe(4); - expect(ql.calls.insert).toBe(0); - expect(ql.rows).toHaveLength(4); - }); - - it('an EMPTY result set is still trusted as "none exist" — the first boot depends on it', async () => { - const ql = permissionQl(declaredSets(3)); - const r = await bootstrapDeclaredPermissions(ql, undefined); - expect(r.seeded).toBe(3); - }); -}); - -/** - * The batched oracle is a snapshot taken before the loop. Without the - * `remember` write-back, a name declared twice in one batch would take the - * INSERT branch the second time — and the loud ADR-0086 D4 refusal it used to - * produce would become a unique-index rejection nobody reports. - */ -describe('#10946 — a name declared twice in one batch keeps its loud refusal', () => { - it('still reports skippedForeign for a second package declaring the same name', async () => { - const ql = permissionQl([ - { name: 'shared_name', label: 'A', objects: {}, _packageId: 'com.example.a' }, - { name: 'shared_name', label: 'B', objects: {}, _packageId: 'com.example.b' }, - ]); - const warns: string[] = []; - const r = await bootstrapDeclaredPermissions(ql, undefined, { - logger: { info: () => {}, warn: (m) => warns.push(m) }, - }); - expect(r.seeded).toBe(1); - expect(r.skippedForeign).toBe(1); - expect(ql.rows).toHaveLength(1); - expect(ql.rows[0].package_id).toBe('com.example.a'); - // [#17516] Re-anchored from the old prose to the stable token the report - // stamps — the assertion's substance (the refusal is REPORTED, not merely - // counted) is unchanged, and the record is asserted beside it so "loud" - // means reaching a reader rather than moving a counter. - expect(warns.some((w) => w.includes('permission_set_name_collision'))).toBe(true); - expect(r.collisions).toEqual([ - expect.objectContaining({ name: 'shared_name', declaredBy: 'com.example.b', ownedBy: 'com.example.a' }), - ]); - }); -}); - -/** - * [commit 1a6855226] `buildExistingByName` gained an optional bucket predicate for - * the curated capability read. The fence below holds the callers that pass - * none to the exact key set they emitted before. - */ -describe('#11451 — a caller that passes no predicate emits its old key set', () => { - it('the existing callers still emit their exact key set — no predicate leaked in', async () => { - // The measurement fence: `buildExistingByName` gained an optional predicate, - // and a caller that passes none must emit the keys it emitted before, not - // those keys plus `undefined`-valued ones. - const ql = permissionQl(declaredSets(3)); - await bootstrapDeclaredPermissions(ql, undefined); - ql.reset(); - await bootstrapDeclaredPermissions(ql, undefined); - expect(Object.keys(ql.wheres[0])).toEqual(['name']); - }); -}); diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts index e81d0383373..d74321a5e4b 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -44,7 +44,6 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { CAPABILITY_PLATFORM_NAME_REFUSED } from './seed-refusal-diagnostics.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -255,7 +254,6 @@ describe.each(SCENARIOS)('a $posture boot, the stack capability $declared', ({ p it('no capability seeder refuses or reports the platform’s own declarations', () => { for (const booted of [fresh, reseeded]) { - expect(booted.warnings.filter((w) => w.includes(CAPABILITY_PLATFORM_NAME_REFUSED))).toEqual([]); expect(booted.warnings.filter((w) => w.includes('capabilit'))).toEqual([]); } }); diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts index 37b9dbfb40b..90baecf594f 100644 --- a/packages/plugins/plugin-security/src/declared-capability-context.ts +++ b/packages/plugins/plugin-security/src/declared-capability-context.ts @@ -44,7 +44,31 @@ */ import type { AnchorBindingContext } from '@objectstack/spec/security'; -import { readDeclared } from './bootstrap-declared-permissions.js'; + +/** + * Read the declared metadata items of a type from the engine's + * `SchemaRegistry` — every package manifest's items, provenance-stamped with + * `_packageId`, plus the environment items `loadMetaFromDb` hydrates into the + * same collection. The registry is the catalog's one home (ADR-0131 D3); this + * is a read of it, never of a catalog row. + * + * [#8378] The registered item IS the authoring document — there is no + * `{ name, content }` envelope to unwrap: `registerMetadataCollections` + * (objectql `engine.ts`) registers each stack-collection element as-is, and + * `loadMetaFromDb` registers the parsed body, never the `sys_metadata` row. + * + * An unreadable or absent registry answers `[]`; the callers fall back to the + * metadata service or fail closed on an empty answer. + */ +export function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?.registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} /** * Read this stack's declared authorization capabilities as an diff --git a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts deleted file mode 100644 index b87b77821fb..00000000000 --- a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #21322 — a permission set registered AFTER the boot is projected into - * `sys_permission_set` when the kernel announces `metadata:reloaded`. - * - * ## The defect these pins are written against - * - * The declared-permission seeding (`bootstrapDeclaredPermissions`, ADR-0086 - * D5) ran once, inside the `kernel:ready` bootstrap, over whatever the engine - * registry held at that moment. A package registered later — `os package - * install` into a running runtime, whose install-local plugin registers the - * package from an HTTP request — was never projected: the evaluator resolved - * its set (`/meta/permission` listed it) while `sys_permission_set` had no - * row, so no admin could grant it until a restart re-ran the boot pass. - * - * The fix re-runs the SAME seeding on `metadata:reloaded`, the post-boot - * re-sync signal every runtime door announces. These pins drive the real - * `SecurityPlugin` through its own hooks — the wiring is the fix — against an - * engine double whose registry is mutable, so "registered after the boot" is - * a real ordering and not a fixture flag. The CLI suite - * `package-install-local-boot-steps.integration.test.ts` pins the same - * outcome end to end through `os package install`. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { SecurityPlugin } from './security-plugin.js'; - -const PACKAGE_ID = 'com.example.tasksapp'; -const PACKAGE_SET = { - name: 'tasks_app_task_user', - label: 'Tasks App Task User', - objects: { tasks_app_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - // The registry's provenance stamp (ADR-0010) — what `registerApp` leaves on - // every item a package contributes, and what the seeder reads as the owner. - _packageId: PACKAGE_ID, -}; - -/** - * `where` as the seeder spells it: field equality plus `{ name: { $in: [...] } }`. - * Anything else is REFUSED rather than answered — a combinator read as a field - * name matches no row and would report that as "no rows". - */ -function rowMatches(row: any, where: Record = {}): boolean { - return Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) { - throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } - const actual = row?.[k] ?? null; - if (v !== null && typeof v === 'object' && !Array.isArray(v)) { - const ops = Object.keys(v as Record); - if (ops.length === 1 && ops[0] === '$in') { - return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); - } - throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); - } - return actual === (v ?? null); - }); -} - -/** Boot the real plugin over an in-memory engine whose `permission` registry the test grows. */ -async function boot() { - const tables: Record = {}; - const declaredPermissions: any[] = []; - const ql: any = { - registerMiddleware: () => {}, - // Only the identity objects the plugin's boot gate requires: a kernel that - // boots it registers them (`authz-identity-objects-boot-refusal.test.ts`). - getSchema: (name: string) => - name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, - registry: { - listItems: (type: string) => (type === 'permission' ? [...declaredPermissions] : []), - }, - find: async (object: string, opts?: any) => { - const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); - // The caller's bound, applied BY PRESENCE and after the filter - // (`check:objectql-double-limit`). - return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); - }, - findOne: async (object: string, query?: EngineFindOneQueryInput) => { - // The producer's own predicate, never a hand-mirrored guard. - assertEngineFindOnePredicate(object, query); - return null; - }, - count: async () => 0, - insert: async (object: string, data: any) => { - const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; - (tables[object] ??= []).push(row); - return { ...row }; - }, - update: async (object: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - const target = (tables[object] ?? []).find((r) => r.id === (data?.id ?? options?.where?.id)); - if (target) Object.assign(target, data); - return target ? 1 : 0; - }, - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: ql, - metadata: { get: async () => null, list: async () => [] }, - }; - const hook = vi.fn(); - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - hook, - }; - const plugin = new SecurityPlugin(); - await plugin.init(ctx); - await plugin.start(ctx); - const fire = async (event: string) => { - for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb({ changed: [`app/${PACKAGE_ID}`] }); - }; - const packageRows = () => (tables.sys_permission_set ?? []).filter((r) => r.name === PACKAGE_SET.name); - return { ctx, fire, packageRows, declaredPermissions }; -} - -describe('#21322: declared permission sets registered after the boot are projected on metadata:reloaded', () => { - it('a set registered after kernel:ready gets its sys_permission_set row on the reload, with package provenance', async () => { - const { ctx, fire, packageRows, declaredPermissions } = await boot(); - await fire('kernel:ready'); - // Positive control: the boot pass really completed — without it, every - // assertion below would be satisfied by a boot that fell over early. - expect( - ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), - ).toHaveLength(1); - expect(packageRows(), 'the package was not registered yet — the boot pass had nothing to project').toHaveLength(0); - - declaredPermissions.push(PACKAGE_SET); // the hot install registers the package - await fire('metadata:reloaded'); - - expect(packageRows()).toHaveLength(1); - expect(packageRows()[0]).toMatchObject({ managed_by: 'package', package_id: PACKAGE_ID }); - }); - - it('is idempotent: a second reload over the same declaration adds no row', async () => { - const { fire, packageRows, declaredPermissions } = await boot(); - await fire('kernel:ready'); - declaredPermissions.push(PACKAGE_SET); - await fire('metadata:reloaded'); - await fire('metadata:reloaded'); - expect(packageRows()).toHaveLength(1); - }); - - it('does nothing before the boot pass has run — the platform defaults are seeded first, in their own shape', async () => { - const { fire, packageRows, declaredPermissions } = await boot(); - declaredPermissions.push(PACKAGE_SET); - await fire('metadata:reloaded'); - expect(packageRows()).toHaveLength(0); - // …and the boot pass that follows still projects it, as it always did. - await fire('kernel:ready'); - expect(packageRows()).toHaveLength(1); - }); -}); diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index e47e483edc3..c58beb5d571 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -17,16 +17,18 @@ * Every seam answered `[object Array]`, with no own `records` key, on a * populated page and an empty one alike. * - * ⚠️ These are six pins, not one pin repeated. A single reading of "the engine + * ⚠️ These are four pins, not one pin repeated. A single reading of "the engine * returns an array" would say nothing about whether a given seam is even - * reachable — six of them sit behind a trigger (a truncated page, a refused - * bulk write, a publish-materializer call path), and a seam nothing reaches is + * reachable — some of them sit behind a trigger (a truncated page, a refused + * bulk write), and a seam nothing reaches is * precisely how a dead limb survives review. So each case DRIVES its own block * through the real function and asserts the answer that block produced, and * {@link assertBareArrayPage} then reads back the value the engine handed it. * - * The seventh seam, `security-plugin.ts`'s `sys_permission_set` loader, is - * gone: the plugin takes a set's body from the security catalog + * Blocks 3 and 6 (the declared-permission seeder's per-name read and the + * `managed_by` normalizer's scan) retired with their modules (ADR-0131 D3: + * the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s + * `sys_permission_set` loader, is gone too: the plugin takes a set's body from the security catalog * (ADR-0131 D3/D4) and reads no `sys_permission_set` row, so it has no * engine page to pin. Its fault direction (a read that did not answer * propagates, and `PermissionEvaluator`'s #2565 warn fires) is pinned on the @@ -51,10 +53,8 @@ import { } from '@objectstack/metadata-core'; import { buildExistingByName } from './seed-name-lookup.js'; -import { upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; const SYS = { context: { isSystem: true } } as any; const ORG = 'org_a'; @@ -243,29 +243,6 @@ describe('[#15598] block 2 — seed-name-lookup perItemIndex (the DEGRADATION re }, 120_000); }); -describe('[#15598] block 3 — bootstrap-declared-permissions defaultLookup', () => { - it('answers from one bare array on the publish-materializer path', async () => { - const engine = await boot(); - const seen: Seen = []; - - // No `existingByName` — the ADR-0086 P2 publish materializer path, which is - // the only caller that reaches `defaultLookup`'s own read. - const outcome = await upsertPackagePermissionSet( - observed(engine, seen), - { name: 'ps_published', label: 'Published', objects: {} } as any, - 'com.acme.crm', - undefined, - {} as any, - ); - - assertBareArrayPage(seen, 'defaultLookup'); - // `unreadable` is what a seam that cannot read its page reports, and it is - // one-hot with `seeded` — so this pair is the discriminator. - expect(outcome.unreadable).toBe(0); - expect(outcome.seeded).toBe(1); - }, 120_000); -}); - describe('[#15598] block 4 — auto-org-admin-grant tryFind', () => { it('reads the set and the membership out of bare arrays, and grants on them', async () => { const engine = await boot(); @@ -321,19 +298,3 @@ describe('[#15598] block 5 — claim-seed-ownership idsFrom (the PAGING fallback expect(rows[0].owner_id).toBe(ADMIN); }, 120_000); }); - -describe('[#15598] block 6 — normalize-managed-by tryFind', () => { - it('reads the legacy rows out of one bare array and rewrites them', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const seen: Seen = []; - - const counts = await normalizeManagedByVocab(observed(engine, seen), {}); - - assertBareArrayPage(seen, 'normalize-managed-by tryFind'); - // The rewrite happened, which it cannot if the scan read nothing. - expect(counts.positions).toBe(1); - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('platform'); - }, 120_000); -}); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 7ba8d61c5dc..1d87a5bf8e4 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -54,11 +54,9 @@ export type { InvitationPlacementIntent, InvitationPlacementService, } from './invitation-placement.js'; -export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; // [#17516] The set-name collision diagnostic. EXPORTED because its whole -// purpose is to be the ONE derivation every door shares: the runtime door below -// raises it today, and the compile-time door (`os build` / `os validate`, which -// lives in another package) must consume these rather than re-deriving either +// purpose is to be the ONE derivation every door shares: the compile-time door +// (`os build` / `os validate`, which lives in another package) must consume these rather than re-deriving either // the predicate or the wording — that drift is what this card is about, one // layer up. export { @@ -72,32 +70,6 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-name-collision.js'; -// [#18091] The seeders' remaining refusals. EXPORTED on the criterion the -// block above states and no wider: a refusal an AUTHOR can cause has a second -// door by construction — the author-time one (`@objectstack/lint`, `os build` / -// `os validate`) — and a door that re-spells the token or re-derives the -// wording is the drift these modules exist to prevent. A package declaring a -// CURATED platform capability name, and a declaration carrying no owning -// package, are both decidable from the declaration alone with no database. -// ⛔ The two unreadable-rows summaries are deliberately NOT here: an unreadable -// database is a runtime condition no compile-time door can raise, so they stay -// package-private for the reason `position_name_fold_grant` does. -export { - CAPABILITY_DECLARATION_UNOWNED, - CAPABILITY_PLATFORM_NAME_REFUSED, - PERMISSION_SET_DECLARATION_UNOWNED, - capabilityDeclarationUnownedDiagnostic, - capabilityPlatformNameRefusedDiagnostic, - permissionSetDeclarationUnownedDiagnostic, - reportCapabilityDeclarationUnowned, - reportCapabilityPlatformNameRefused, - reportPermissionSetDeclarationUnowned, -} from './seed-refusal-diagnostics.js'; -export type { - CapabilityDeclarationUnownedDiagnostic, - CapabilityPlatformNameRefusedDiagnostic, - PermissionSetDeclarationUnownedDiagnostic, -} from './seed-refusal-diagnostics.js'; // [ADR-0094] sys_permission_set pure-projection machinery. export { permissionSetRowFields, @@ -119,7 +91,6 @@ export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissi export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; export type { ObjectPostureGateContext } from './object-posture-gate.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; -export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName, appSecurityPluginOptions, @@ -149,18 +120,6 @@ export type { SuggestionReconcileOutcome, WithheldAcceptance, } from './suggested-audience-bindings.js'; -// [field report — rc→GA declared≠enforced surfacing] "declared ≠ enforced" -// per-set diagnostics (overlay_shadow / provenance_skip). -export { - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - runPermissionSetDriftDiagnostics, -} from './permission-set-drift.js'; -export type { - PermissionSetDriftStatus, - PermissionSetDriftDiagnostic, - DriftDiagnosticsOptions, -} from './permission-set-drift.js'; // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] // Lock the base, clone to customize: the write-door provenance rule and its // refusals, plus the DETECTION READING for overlays that already exist. diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.test.ts b/packages/plugins/plugin-security/src/normalize-managed-by.test.ts deleted file mode 100644 index 502b878cc24..00000000000 --- a/packages/plugins/plugin-security/src/normalize-managed-by.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; - -/** - * A4 #2920 — the boot reconciler that heals legacy `managed_by` values on the - * RBAC catalogs onto the unified platform/package/admin vocabulary. - */ -function makeQl() { - const tables: Record = { - sys_position: [], - sys_permission_set: [], - }; - return { - tables, - async find(object: string, opts: any) { - const where = opts?.where ?? {}; - return (tables[object] ?? []).filter((r) => - Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }), - ); - }, - async update(object: string, data: any) { - const row = (tables[object] ?? []).find((r) => r.id === data.id); - if (row) Object.assign(row, data); - return row; - }, - }; -} - -describe('normalizeManagedByVocab (A4 #2920)', () => { - it('rewrites legacy position values system/config/user -> platform/package/admin', async () => { - const ql = makeQl(); - ql.tables.sys_position.push( - { id: 'p1', managed_by: 'system' }, - { id: 'p2', managed_by: 'config' }, - { id: 'p3', managed_by: 'user' }, - { id: 'p4', managed_by: 'platform' }, // already canonical - ); - const res = await normalizeManagedByVocab(ql); - expect(res.positions).toBe(3); - expect(ql.tables.sys_position.map((r) => r.managed_by).sort()).toEqual([ - 'admin', - 'package', - 'platform', - 'platform', - ]); - }); - - it('rewrites legacy permission-set value user -> admin, leaving platform/package untouched', async () => { - const ql = makeQl(); - ql.tables.sys_permission_set.push( - { id: 's1', managed_by: 'user' }, - { id: 's2', managed_by: 'package' }, - { id: 's3', managed_by: 'platform' }, - { id: 's4', managed_by: 'admin' }, - ); - const res = await normalizeManagedByVocab(ql); - expect(res.permissionSets).toBe(1); - expect(ql.tables.sys_permission_set.find((r) => r.id === 's1')!.managed_by).toBe('admin'); - expect(ql.tables.sys_permission_set.find((r) => r.id === 's2')!.managed_by).toBe('package'); - }); - - it('is idempotent — a second run is a no-op', async () => { - const ql = makeQl(); - ql.tables.sys_position.push({ id: 'p1', managed_by: 'system' }); - ql.tables.sys_permission_set.push({ id: 's1', managed_by: 'user' }); - await normalizeManagedByVocab(ql); - const res2 = await normalizeManagedByVocab(ql); - expect(res2).toEqual({ positions: 0, permissionSets: 0 }); - }); - - it('tolerates a ql without find/update', async () => { - expect(await normalizeManagedByVocab(null as any)).toEqual({ positions: 0, permissionSets: 0 }); - expect(await normalizeManagedByVocab({} as any)).toEqual({ positions: 0, permissionSets: 0 }); - }); -}); diff --git a/packages/plugins/plugin-security/src/normalize-managed-by.ts b/packages/plugins/plugin-security/src/normalize-managed-by.ts deleted file mode 100644 index e3f38ae0526..00000000000 --- a/packages/plugins/plugin-security/src/normalize-managed-by.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * normalizeManagedByVocab — heal legacy `managed_by` values on the RBAC - * catalogs to the unified tri-state vocabulary (A4 #2920). - * - * The three RBAC catalogs (`sys_capability`, `sys_permission_set`, - * `sys_position`) historically spoke three different provenance dialects: - * - capability: platform / package / admin (already canonical) - * - permission set: platform / package / user - * - position: system / config / user - * - * A4 unifies all three on **platform / package / admin**. New rows are written - * canonically by the seeders/projector; this reconciler rewrites the residual - * legacy values on rows those writers do NOT re-touch — env-authored permission - * sets stamped `'user'`, and older tenant positions stamped `'system'` / - * `'config'` / `'user'`. Built-in position rows and declared package sets - * self-heal on their own bootstrap upsert, so this only mops up the rest. - * - * NOT purely cosmetic: the system-row write gate's provenance map - * (SYSTEM_ROW_PROVENANCE in security-plugin.ts) branches on `managed_by` - * values, so it must recognize BOTH the canonical and the legacy vocabulary — - * renaming a stored value without updating that map silently disarms the gate - * (#2926 ①). Keep the two in lockstep whenever this vocabulary changes. - * Idempotent: canonical rows are skipped, so a re-run is a no-op. - * Non-fatal to boot, like the sibling boot reconcilers — but [#15840] no longer - * best-effort about its own reads: a catalog read that does not answer refuses - * the pass instead of reporting the same counts an already-canonical catalog - * reports. The `kernel:ready` caller catches the refusal and carries on. - * - * Runs on `kernel:ready` after the seeders, as `isSystem` (the field is - * `readonly`, so only a system write may set it). - */ - -const SYSTEM_CTX = { isSystem: true }; - -/** legacy value -> canonical value, per object. */ -const POSITION_MAP: Record = { - system: 'platform', - config: 'package', - user: 'admin', -}; -const PERMISSION_SET_MAP: Record = { - user: 'admin', -}; - -interface NormalizeOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - /** - * [#15840] The level the ruling names for a refused pass. Optional, unlike - * its two siblings, so every caller that compiles today still compiles: it - * is an input this module asks for, not a channel it publishes. - * - * ⚠️ Three parameters, not two: the platform `Logger` contract - * (`packages/spec/src/contracts/logger.ts`) takes the `Error` in its OWN - * second argument at this level and only this level. Declaring the sibling - * `(message, meta)` shape here makes the real `ctx.logger` unassignable. - */ - error?: (message: string, error?: Error, meta?: Record) => void; - }; -} - -/** - * [#15840] Read the legacy rows, or REFUSE — never invent an empty catalog. - * - * The `catch { return []; }` that stood here is the read-seam invention rule's - * worst case, and it was measured (report 5553806224): an unreadable catalog and - * an already-canonical one were BYTE-IDENTICAL on both channels — the same - * `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while - * the row that needed healing stayed legacy. "I could not read the catalog" was - * reported as "the catalog is already canonical". - * - * #15840's ruling (decision batch #105 item 5, option A) is that a read fault - * REFUSES the normalisation pass for that batch and reports at `error`; ⛔ it - * never answers "already canonical". So the fault leaves this function as a - * throw, and {@link normalizeManagedByVocab} lets it out. - * - * The consumer contract for that throw already exists and is the reason a - * refusal is decidable here at all: `security-plugin.ts`'s `kernel:ready` - * bootstrap wraps this call in `try { … } catch { logger.warn('[security] - * managed_by vocab normalization failed (non-fatal)') }`, so boot proceeds and - * the remaining bootstrap steps still run. Nothing reached that handler before, - * because the fault was swallowed one frame below. - * - * ⚠️ Refusing is also the CHEAPER report. The report-and-continue option was - * measured at four lines per boot — this pass calls the read once per legacy - * value, three for `sys_position` and one for `sys_permission_set` — so a - * whole-catalog outage said the same thing four times. A refusal aborts at the - * first un-answered read, which is exactly one `error` line per refused boot. - * - * ⛔ Not a relaxation: the system-row write gate's provenance map recognizes - * BOTH the canonical and the legacy vocabulary (see the header note and - * #2926 ①), so rows left un-normalised by a refusal are still gated. Nothing is - * granted, widened or disarmed by declining to rewrite them; the next boot - * asks again. - */ -function readRefused(object: string, cause?: unknown): Error { - const why = cause === undefined ? 'the engine did not answer with a row array' : (cause as Error)?.message; - return new Error( - `[security] managed_by normalize REFUSED for ${object} — the catalog read did not answer, ` + - `so this pass cannot tell "already canonical" from "could not ask": ${why}`, - ); -} - -async function findOrRefuse(ql: any, object: string, where: any): Promise { - let rows: any; - try { - rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX }); - } catch (e) { - throw readRefused(object, e); - } - // Bare array, driven — `engine-find-bare-array.pin.test.ts` boots a real - // engine over a real `SqlDriver` and pins this seam. The `{ records }` limb - // that stood here was dead code that read as a contract; a non-array answer - // is now a refusal for the same reason a throw is — the pass did not get the - // rows, so it must not report on them. - if (Array.isArray(rows)) return rows; - throw readRefused(object); -} - -async function normalizeObject( - ql: any, - object: string, - map: Record, - logger?: NormalizeOptions['logger'], -): Promise { - let updated = 0; - for (const [legacy, canonical] of Object.entries(map)) { - // Narrow equality scan per legacy value keeps the where-clause - // driver-portable (no IN / OR predicate). - let rows: any[]; - try { - rows = await findOrRefuse(ql, object, { managed_by: legacy }); - } catch (e) { - // [#15840] The one report the ruling names, emitted where the count that - // will NOT be returned is still known: rows healed before the refusal - // stay healed, and saying so is the difference between a refusal and a - // rollback. Reported once — the throw aborts the whole pass. - logger?.error?.((e as Error).message, e instanceof Error ? e : undefined, { - object, - legacyValue: legacy, - healedBeforeRefusal: updated, - consequence: - 'the remaining legacy rows keep their legacy managed_by; the write gate recognizes ' + - 'both vocabularies, so nothing is disarmed, and the next boot asks again', - }); - throw e; - } - for (const row of rows) { - if (!row?.id) continue; - try { - await ql.update(object, { id: row.id, managed_by: canonical }, { context: SYSTEM_CTX }); - updated += 1; - } catch (e) { - logger?.warn?.(`[security] managed_by normalize failed for ${object}:${row.id}`, { - error: (e as Error).message, - }); - } - } - } - return updated; -} - -/** - * Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position` - * to the unified tri-state vocab. Returns a per-object count of rows healed. - * - * [#15840] THROWS if a catalog read does not answer. The returned counts are an - * attestation — "these rows were legacy and are now canonical" — and a pass that - * could not read the catalog has nothing to attest, so it refuses rather than - * reporting `{ positions: 0, permissionSets: 0 }`, which is what an - * already-canonical catalog reports. Callers already handle this: the - * `kernel:ready` bootstrap catches it, reports at `warn` as non-fatal, and - * continues. An engine with no `find`/`update` at all is NOT a read fault and - * still returns zeros. - */ -export async function normalizeManagedByVocab( - ql: any, - options: NormalizeOptions = {}, -): Promise<{ permissionSets: number; positions: number }> { - if (!ql || typeof ql.find !== 'function' || typeof ql.update !== 'function') { - return { permissionSets: 0, positions: 0 }; - } - const positions = await normalizeObject(ql, 'sys_position', POSITION_MAP, options.logger); - const permissionSets = await normalizeObject(ql, 'sys_permission_set', PERMISSION_SET_MAP, options.logger); - const total = positions + permissionSets; - if (total > 0) { - options.logger?.info?.('[security] managed_by vocab normalized to platform/package/admin, the one vocabulary every RBAC catalog shares', { - positions, - permissionSets, - }); - } - return { permissionSets, positions }; -} diff --git a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts index f03e9a2746e..10b5df747dd 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -1,48 +1,34 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#10103] The RBAC catalog is materialized PER ORGANIZATION. + * [#10103] The cross-organization grant bleed stays closed on the REAL + * resolver. * - * ## What was measured, and why these cases exist - * - * On a walled deployment every principal — org owner and platform admin alike — - * listed ZERO positions, permission sets and sharing rules while the tables held - * rows. The rows were all organization-less: this plugin's Layer 0 composes a - * strict `organization_id = :tenant`, the middleware ANDs it over the driver's - * `(organization_id = :tenant OR organization_id IS NULL)`, and the conjunction - * of the two is the strict equality alone. Nothing could be administered and a - * declared `hierarchy-security` could never be armed. - * - * The repair does not touch the wall at either layer. It gives the rows an - * owner. So every case here asserts about ROWS AND THEIR ORGANIZATION, through - * the real shipped seeders, never about a wall predicate. + * Cases 1–5 of this file pinned the per-organization materialization of the + * permission-set catalog rows. That materialization retired with the + * declared-permission seeder (ADR-0131 D3: the boot writes no catalog row, and + * every organization reads the same registry), so they went with it. What + * remains is the enforcement-plane half: an existing database still holds the + * per-organization `everyone` copies and junction rows the retired seeders + * wrote, and no organization's member may collect another organization's + * binding through them. * * ## Why a real driver, and a real engine * - * The whole defect lives in the interaction between a scope the ENGINE threads - * and a predicate the DRIVER emits. A hand-written engine double implements - * neither, so it reports green on a `tenantId` the real stack never applies — - * and it is precisely a double's silence that let the shipped behaviour read as - * correct for as long as it did. These cases therefore run a real `SqlDriver` on - * better-sqlite3 `:memory:` behind a real `ObjectQL`, and call the SHIPPED - * seeder functions rather than re-implementing their upserts. - * - * `case 6` additionally imports the REAL `resolveUserAuthzGrants` from - * `@objectstack/core` — the enforcement-plane half of this change — so the - * cross-organization grant bleed is pinned against the shipped resolver rather - * than a transcription of its shape. That import resolves through the package's - * `exports` to core's `dist/`, so core must be BUILT for this case to mean - * anything; the suite's `pnpm test` run is preceded by a dependency-closure - * build for exactly that reason. + * The defect lived in the interaction between a scope the ENGINE threads and a + * predicate the DRIVER emits, so this case runs a real `SqlDriver` on + * better-sqlite3 `:memory:` behind a real `ObjectQL`, and imports the REAL + * `resolveUserAuthzGrants` from `@objectstack/core`. That import resolves + * through the package's `exports` to core's `dist/`, so core must be BUILT for + * this case to mean anything; the suite's `pnpm test` run is preceded by a + * dependency-closure build for exactly that reason. */ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveUserAuthzGrants } from '@objectstack/core'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; @@ -62,35 +48,6 @@ afterEach(async () => { } }); -/** - * One declared position and one packaged permission set. [ADR-0131 D2] The - * position is declared and gets no row: no boot seeder writes `sys_position` - * any more, which case 1 holds as a census beside the permission set's rows. - */ -const STUB_REGISTRY = { - listItems: (type: string) => { - if (type === 'position') return [{ name: 'sales_manager', label: 'Sales Manager' }]; - if (type === 'permission') return [{ name: 'sales_readonly', label: 'Sales RO', _packageId: 'com.acme.crm', objects: {} }]; - return []; - }, - getItem: (type: string, name: string) => STUB_REGISTRY.listItems(type).find((item) => item.name === name), - isPackageDisabled: () => false, -}; - -/** A logger that records what the seeders said, so a LOUD guard can be asserted. */ -function recordingLogger() { - const warns: Array<{ message: string; meta: any }> = []; - const infos: Array<{ message: string; meta: any }> = []; - return { - warns, - infos, - logger: { - info: (message: string, meta?: any) => { infos.push({ message, meta }); }, - warn: (message: string, meta?: any) => { warns.push({ message, meta }); }, - }, - }; -} - async function boot(): Promise { const engine = new ObjectQL(); engine.registerDriver( @@ -124,176 +81,15 @@ async function boot(): Promise { return engine; } -/** - * The REAL engine, faced with the stub registry (`ObjectQL.registry` is a - * getter, so it cannot simply be assigned). - * - * A delegating pass-through, not a fake: every verb reaches the real ObjectQL - * behind it, which is the whole point of this suite. `update` still opens with - * the PRODUCER's own dispatch predicate (#4550 / #5480 / #6277) rather than a - * hand-mirrored guard, because a seam that merely forwards is exactly the shape - * that reads as "not a double" and then admits a call the real engine would - * reject. `delete` is not declared at all — the seeder does not delete, - * and a verb no caller exercises would be a contract this seam does not have to - * make. - */ -function withRegistry(engine: any): any { - return { - find: (o: string, q?: any, opt?: any) => engine.find(o, q, opt), - insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), - update: (o: string, d: any, opt?: any) => { - assertEngineUpdateDispatch(d, opt); - return engine.update(o, d, opt); - }, - registry: STUB_REGISTRY, - }; -} - /** Ground truth: every stored row, straight off knex, past all tenancy. */ async function stored(engine: ObjectQL, table: string): Promise { const driver: any = (engine as any).getDriver(table); return driver.knex(table).select('*'); } -/** - * Run the catalog seeder for one organization (walled), or none (single). - * [ADR-0131 D2] The two position seeders are retired; the permission-set - * seeder is the one left here. - */ -async function seedCatalog(engine: ObjectQL, logger: any, organizationId?: string): Promise { - const ql = withRegistry(engine); - await bootstrapDeclaredPermissions(ql, null, { logger, organizationId }); -} - const orgOf = (r: any): string | null => (r.organization_id ?? null); -describe('[#10103] per-organization RBAC catalog materialization', () => { - it('1. walled: every seeded catalog row is STAMPED with its organization, and each organization holds its own copy', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - const positions = await stored(engine, 'sys_position'); - const sets = await stored(engine, 'sys_permission_set'); - - // [ADR-0131 D2] No position row at all — the declared `sales_manager` and - // the six built-ins are registry items, written to no table. - expect(positions).toEqual([]); - - // The inversion of the shipped behaviour: no row belongs to nobody. - expect(sets.filter((r) => orgOf(r) === null)).toEqual([]); - - // Stated per organization so a failure names the tenant that lost its - // catalog rather than printing a set diff. - for (const org of ORGS) { - expect(sets.filter((r) => orgOf(r) === org).map((r) => r.name)).toEqual(['sales_readonly']); - } - }); - - it('2. walled: the read that measured ZERO over the whole catalog now returns the organization’s own rows', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - // The AST plugin-security's Layer 0 AND-composes for `isolated`: a strict - // `organization_id = `, over which the driver adds its own - // predicate. This is the exact read that returned nothing. - const walled = await (engine as any).find('sys_permission_set', { - where: { organization_id: ORG_JIA }, - context: { isSystem: true, tenantId: ORG_JIA }, - }); - const names = (walled as any[]).map((r) => r.name).sort(); - expect(names).toEqual(['sales_readonly']); - - // And the wall still walls: org_yi's copies are not in org_jia's read. - for (const row of walled as any[]) expect(orgOf(row)).toBe(ORG_JIA); - }); - - it('3. THE silent no-op is gone: a per-organization pass over a PRE-FIX organization-less row creates the organization’s own copy AND says so loudly, by name', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - - // Reproduce a pre-fix deployment exactly: one organization-less pass, the - // shipped behaviour, writing the rows every deployment that booted the old - // code carries. - await seedCatalog(engine, logger, undefined); - const before = await stored(engine, 'sys_permission_set'); - expect(before.every((r) => orgOf(r) === null)).toBe(true); - const setBefore = before.filter((r) => r.name === 'sales_readonly'); - expect(setBefore).toHaveLength(1); - - warns.length = 0; - for (const org of ORGS) await seedCatalog(engine, logger, org); - - // (a) NOT a no-op. The measured failure was: the tenant-threaded pass sees - // the organization-less row through the driver's compatibility arm, reads - // the name as already represented, takes the update branch and creates - // nothing — leaving the deployment as broken as before while reporting - // success. Each organization now has its OWN `sales_readonly`. - const after = await stored(engine, 'sys_permission_set'); - const setAfter = after.filter((r) => r.name === 'sales_readonly'); - // Sorted with `null` spelled explicitly rather than left to the default - // comparator, which stringifies and orders `null` first. - expect(setAfter.map(orgOf).sort((a, b) => `${a}`.localeCompare(`${b}`))) - .toEqual([ORG_JIA, ORG_YI, null].sort((a, b) => `${a}`.localeCompare(`${b}`))); - expect(setAfter.filter((r) => orgOf(r) === ORG_JIA)).toHaveLength(1); - expect(setAfter.filter((r) => orgOf(r) === ORG_YI)).toHaveLength(1); - - // (b) LOUD. Named rows, named remedy — this is what stands in place of the - // reap, so a warning that merely counted would not discharge it. - const guard = warns.filter((w) => w.message.includes('pre-fix organization-less')); - expect(guard.length).toBeGreaterThanOrEqual(2); // at least once per organization - for (const org of ORGS) { - const forOrg = guard.filter((w) => w.meta?.organization === org); - expect(forOrg.length).toBeGreaterThan(0); - const names = forOrg.flatMap((w) => w.meta?.names ?? []); - expect(names).toContain('sales_readonly'); - } - // The remedy is spelled out, and the reason nothing is deleted with it. - const text = guard.map((w) => w.message).join(' '); - expect(text).toContain('re-initialize'); - expect(text).toContain('adopt'); - expect(text).toContain('NOT deleted'); - - // (c) and nothing was reaped — the organization-less row that grants point - // at is still there, which is why the guard is a warning and not a delete. - expect(setAfter.filter((r) => orgOf(r) === null)).toHaveLength(1); - }); - - it('4. `single`-posture carve-out: exactly ONE organization-less pass, no copies, and NO warning', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - - await seedCatalog(engine, logger, undefined); - await seedCatalog(engine, logger, undefined); // idempotent re-run, as at boot - - const sets = await stored(engine, 'sys_permission_set'); - expect(sets.every((r) => orgOf(r) === null)).toBe(true); - expect(sets.filter((r) => r.name === 'sales_readonly')).toHaveLength(1); - // An organization-less row is the CORRECT shape here, so the guard that - // calls it invalid state must stay silent. - expect(warns.filter((w) => w.message.includes('pre-fix organization-less'))).toEqual([]); - }); - - it('5. boot reconciliation is O(changed declarations): a second pass over unchanged declarations writes NOTHING', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - for (const org of ORGS) await seedCatalog(engine, logger, org); - - // `updated_at` is the observable a blind re-write would move. Snapshot the - // whole row set, re-run every pass, and require byte-equality — a pass that - // re-wrote unchanged rows would fail here even if the VALUES matched. - const snapshot = JSON.stringify((await stored(engine, 'sys_position')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - const setSnapshot = JSON.stringify((await stored(engine, 'sys_permission_set')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - for (const org of ORGS) await seedCatalog(engine, logger, org); - - const again = JSON.stringify((await stored(engine, 'sys_position')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - const setAgain = JSON.stringify((await stored(engine, 'sys_permission_set')).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - expect(again).toBe(snapshot); - expect(setAgain).toBe(setSnapshot); - }); - +describe('[#10103] per-organization RBAC catalog — the resolver', () => { it('6. the cross-organization grant bleed is closed: the REAL resolver collects no organization’s junction binding', async () => { const engine = await boot(); // [ADR-0131 D2] No boot seeder writes `everyone` rows any more; an existing diff --git a/packages/plugins/plugin-security/src/permission-set-drift.test.ts b/packages/plugins/plugin-security/src/permission-set-drift.test.ts deleted file mode 100644 index f1cf7524ee9..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-drift.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * "declared ≠ enforced" surfacing (field report: rc→GA upgraded envs freeze - * a package-declared permission set's grants, silently). Pins, one per test - * group: - * - * 1. a set whose enforced grants differ from the artifact is surfaced, - * naming the set AND the cause; - * 2. ⭐ the counter-direction — an in-sync set is NOT surfaced (`in_sync` is - * persisted as `null`, never a value that reads as a badge); - * 3. both mechanisms are detected AND correctly attributed, including the - * CONFOUNDED case where `managed_by` is wrong AND an overlay exists - * (the exact field-reported shape) — a detector that finds drift but - * mislabels the cause sends the operator to the wrong remedy. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { - computePermissionSetDriftDiagnostics, - persistPermissionSetDriftDiagnostics, - runPermissionSetDriftDiagnostics, -} from './permission-set-drift.js'; -import { permissionSetRowFields } from './permission-set-projection.js'; - -/** - * Minimal in-memory ql: sys_permission_set + sys_metadata + $in support. - * - * `refuseUpdatesWith` makes every `update` throw AFTER the dispatch predicate - * has accepted the call shape — the real failure ORDER (the engine validates, - * the store refuses), and the only one `tryUpdate`'s catch ever sees. Placing - * the throw first would let a call shape the real engine rejects pass this - * suite. Extended in place rather than added as a second double, deliberately: - * this file's `update` double is pinned 1-per-file in - * `scripts/engine-double-contract.pinned.json`. - */ -function makeQl(declared: any[] = [], refusal: { refuseUpdatesWith?: Error } = {}) { - const permRows: any[] = []; - const metaRows: any[] = []; - const tableFor = (object: string) => - object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; - const matches = (r: any, where: any) => - Object.entries(where ?? {}).every(([k, v]) => { - // REFUSE the combinators this double does not implement rather than - // reading `$and`/`$or` as a column name (check:where-matcher / - // #8494) -- neither code path under test here (drift detection reads - // sys_permission_set/sys_metadata by id/name/type/state, batched by - // `$in` via `buildExistingByName`) ever issues one; a matcher that - // silently treated a combinator as a field name would let a future - // combinator query pass this suite while answering the wrong rows. - if (k.startsWith('$')) { - throw new Error(`fake driver: unsupported combinator ${k}`); - } - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return r[k] === v; - }); - return { - permRows, - metaRows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - const rows = tableFor(object); - return rows ? rows.filter((r) => matches(r, q?.where)) : []; - }, - // Routed through the real dispatch predicate (#4434 / check:engine-double-contract): - // a fake looser than ObjectQL.update would let persistPermissionSetDriftDiagnostics - // drift to a call shape the real engine refuses while this suite stayed green. - async update(object: string, data: any, options?: any) { - const rows = tableFor(object); - const dispatch = assertEngineUpdateDispatch(data, options); - if (refusal.refuseUpdatesWith) throw refusal.refuseUpdatesWith; - const targets = dispatch.kind === 'by-id' - ? (rows ?? []).filter((r: any) => r.id === dispatch.id) - : (rows ?? []).filter((r: any) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'ehr_quality_inspector', - label: 'Quality Inspector', - objects: { - obj_a: { allowRead: true }, obj_b: { allowRead: true }, obj_c: { allowRead: true }, - }, // 3 objects — "the artifact" (stands in for the field report's 49) - _packageId: 'com.example.ehr', - ...over, -}); - -/** - * A row whose stored facets equal `declaredSet()`'s — built from the SAME - * `permissionSetRowFields` projection the real boot seeders write, so this - * fixture cannot drift from what `recordDiffersFromBody` actually compares - * (label / description / all five JSON facet columns). - */ -const inSyncRow = (over: Record = {}) => ({ - id: 'ps_1', - name: 'ehr_quality_inspector', - managed_by: 'package', - package_id: 'com.example.ehr', - ...permissionSetRowFields(declaredSet()), - ...over, -}); - -describe('computePermissionSetDriftDiagnostics — pin 2 (quiet case): an in-sync set is NOT surfaced', () => { - it('a package-owned row whose content matches the artifact reports in_sync (persisted as null)', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow()); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].status).toBe('in_sync'); - expect(diagnostics[0].detail).toBeNull(); - - const { updated } = await persistPermissionSetDriftDiagnostics(ql, diagnostics); - expect(updated).toBe(0); // row already had no drift_status — nothing to write - expect(ql.permRows[0].drift_status).toBeUndefined(); - }); - - it('a steady-state re-run issues ZERO writes once drift_status already reflects the truth (#10946 discipline)', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow({ drift_status: null, drift_detail: null })); - const { updated } = await runPermissionSetDriftDiagnostics(ql); - expect(updated).toBe(0); - }); -}); - -describe('computePermissionSetDriftDiagnostics — pin 1 & 3: both mechanisms detected AND correctly attributed', () => { - it("overlay_shadow: an active sys_metadata overlay shadows a managed_by:'package' row", async () => { - const artifact = declaredSet(); // 3-object artifact - const ql = makeQl([artifact]); - // row currently enforces only 2 objects (the overlay's stale content) - ql.permRows.push(inSyncRow({ - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - })); - ql.metaRows.push({ - id: 'meta_1', type: 'permission', name: 'ehr_quality_inspector', state: 'active', - organization_id: null, - metadata: JSON.stringify({ name: 'ehr_quality_inspector', objects: { obj_a: {}, obj_b: {} } }), - }); - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('overlay_shadow'); - expect(d.detail).toContain('overlay'); - expect(d.detail).not.toContain('Discard'); - }); - - it("provenance_skip: managed_by is NOT 'package' (legacy row), no overlay present", async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', // legacy / pre-provenance insert - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), // frozen at an old, smaller snapshot - })); - // no sys_metadata overlay row at all - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('provenance_skip'); - expect(d.detail).toContain('managed_by'); - expect(d.detail).not.toContain('overlay is shadowing'); // never conflate the two causes' wording - }); - - it('CONFOUNDED case (the exact field-reported shape): managed_by is wrong AND an overlay exists — attributed as overlay_shadow, not provenance_skip', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - // The row's managed_by is wrong (provenance-skip candidate) … - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true }, obj_b: { allowRead: true } }), - })); - // … AND an active overlay is ALSO present for the same name — the - // decisive stale source per the field follow-up. Detection must not be - // fooled by the wrong managed_by into calling this provenance_skip. - ql.metaRows.push({ - id: 'meta_1', type: 'permission', name: 'ehr_quality_inspector', state: 'active', - organization_id: null, - metadata: JSON.stringify({ name: 'ehr_quality_inspector', objects: { obj_a: {}, obj_b: {} } }), - }); - - const [d] = await computePermissionSetDriftDiagnostics(ql); - expect(d.status).toBe('overlay_shadow'); // correctly attributed despite the confound - }); - - it('a set with no owning package is never diagnosed (nothing to compare against)', async () => { - const ql = makeQl([declaredSet({ _packageId: undefined, packageId: undefined })]); - ql.permRows.push(inSyncRow({ managed_by: 'admin', object_permissions: '{}' })); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(0); - }); - - it('a name with no materialized row yet is skipped (boot seeding will create it, nothing to diagnose)', async () => { - const ql = makeQl([declaredSet()]); - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - expect(diagnostics).toHaveLength(0); - }); -}); - -describe('persistPermissionSetDriftDiagnostics — writes are equality-gated', () => { - it('writes drift_status/drift_detail once, then issues zero further writes on an unchanged re-run', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact]); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - - const first = await runPermissionSetDriftDiagnostics(ql); - expect(first.updated).toBe(1); - expect(ql.permRows[0].drift_status).toBe('provenance_skip'); - expect(typeof ql.permRows[0].drift_detail).toBe('string'); - - const second = await runPermissionSetDriftDiagnostics(ql); - expect(second.updated).toBe(0); // nothing changed — no round trip - }); -}); - - -/* ------------------------------------------------------------------------- * - * pin 6 — a REFUSED diagnostic write must be LOUD - * - * The defect: `persistPermissionSetDriftDiagnostics` counted only the writes - * that LANDED, and `runPermissionSetDriftDiagnostics` reported only when that - * count was non-zero. So a boot on which every drift write was refused - * computed the drift correctly, persisted none of it, and printed NOTHING — - * byte-identical to a deployment with no drift, while the drifted sets kept - * enforcing grants that differ from the shipped artifact. - * - * Driver-error spellings are taken from the shipped classifier's own measured - * fixtures (see `seed-write-refusal.test.ts`), never invented here. - * ------------------------------------------------------------------------- */ - -/** Records every channel separately, with `error`'s real 3-arg shape. */ -function recordingLogger() { - const info: any[] = []; - const warn: any[] = []; - const error: any[] = []; - return { - info, warn, error, - sink: { - info: (m: string, meta?: any) => { info.push({ m, meta }); }, - warn: (m: string, meta?: any) => { warn.push({ m, meta }); }, - error: (m: string, e?: any, meta?: any) => { error.push({ m, e, meta }); }, - }, - }; -} - -/** NOT a unique violation — the realistic refusal for an update-by-id. */ -const connectionFailure = () => - Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); - -describe('runPermissionSetDriftDiagnostics — pin 6: a refused diagnostic write is visible', () => { - it('a pass whose every write is REFUSED still reports — the drifted set is named, and the refusal is reported on the durability channel', async () => { - const artifact = declaredSet(); - const ql = makeQl([artifact], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', // provenance_skip — real drift - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - const log = recordingLogger(); - - const result = await runPermissionSetDriftDiagnostics(ql, { logger: log.sink }); - - // The drift was computed correctly and NONE of it landed. - expect(result.diagnostics[0].status).toBe('provenance_skip'); - expect(result.updated).toBe(0); - expect(result.refused).toBe(1); - expect(ql.permRows[0].drift_status).toBeUndefined(); - - // ⭐ The boot is no longer indistinguishable from a clean one. - expect(log.error).toHaveLength(1); - expect(log.error[0].m).toContain('REFUSED'); - expect(log.error[0].meta.refused).toBe(1); - expect(log.error[0].meta.refusals[0]).toMatchObject({ - object: 'sys_permission_set', class: 'other', count: 1, - }); - // The value-free code channel, never the bound statement. - expect(log.error[0].meta.refusals[0].driverCodes).toContain('ECONNREFUSED'); - // Never the catalog-seed prose: this pass seeds nothing, and sending an - // operator after a legacy platform-wide catalog index would be a - // confident wrong answer. See `reportDriftWriteRefusals`. - expect(log.error[0].m).not.toContain('RBAC catalog'); - - // …and the line that NAMES the drifted set is no longer gated behind the - // counter the refusal suppressed. - const drift = log.warn.find((l) => l.m.includes('differ from the shipped artifact')); - expect(drift).toBeDefined(); - expect(drift!.meta.drifted).toEqual([{ name: 'ehr_quality_inspector', status: 'provenance_skip' }]); - expect(drift!.meta.updated).toBe(0); - expect(drift!.meta.refused).toBe(1); - }); - - it('⭐ counter-direction: a steady-state pass (nothing to write, nothing refused) still prints NOTHING', async () => { - const ql = makeQl([declaredSet()]); - ql.permRows.push(inSyncRow({ drift_status: null, drift_detail: null })); - const log = recordingLogger(); - - const result = await runPermissionSetDriftDiagnostics(ql, { logger: log.sink }); - - expect(result.updated).toBe(0); - expect(result.refused).toBe(0); - // The new `refused > 0` limb re-opens the refusal case and nothing else — - // a quiet boot stays exactly as quiet as it was. - expect(log.error).toHaveLength(0); - expect(log.warn).toHaveLength(0); - }); - - it('against a REDUCED sink with no `error`, the refusal still prints — at `warn`, never nowhere', async () => { - const ql = makeQl([declaredSet()], { refuseUpdatesWith: connectionFailure() }); - ql.permRows.push(inSyncRow({ - managed_by: 'admin', - object_permissions: JSON.stringify({ obj_a: { allowRead: true } }), - })); - const warn: any[] = []; - - // `{ warn }` alone is a legal ProjectionLogger — hosts do inject reduced - // sinks, which is exactly why the durability fallback is mandatory. - await runPermissionSetDriftDiagnostics(ql, { logger: { warn: (m: string, meta?: any) => { warn.push({ m, meta }); } } }); - - expect(warn.some((l) => l.m.includes('REFUSED'))).toBe(true); - }); -}); - - -/* ------------------------------------------------------------------------- * - * [#21860] the declared population is the lock classifier's - * - * A set saved into a writable runtime package carries its package id on its - * registry body after the first metadata list read - * (`{ _packageId: , _provenance: 'org' }`). This report read - * that as "package-declared": a drifted runtime-package set with a stored - * definition was diagnosed `overlay_shadow`. - * ------------------------------------------------------------------------- */ - -const RUNTIME_PACKAGE = 'com.dogfood.runtime_pkg'; - -/** Registry bodies as the hydrator registers them for each environment-authored shape. */ -const envShapes = [ - { label: 'runtime-package set', body: { name: 'rt_pkg_set', label: 'Runtime package set', objects: { obj_a: { allowRead: true } }, _packageId: RUNTIME_PACKAGE, _provenance: 'org' }, packageId: RUNTIME_PACKAGE }, - { label: 'org-owned set', body: { name: 'org_owned_set', label: 'Org-owned set', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null }, - { label: 'clone', body: { name: 'contributor_clone', label: 'Contributor (clone)', objects: { obj_a: { allowRead: true } }, _provenance: 'org' }, packageId: null }, - // The lock module's documented runtime shadow: the `'sys_metadata'` sentinel package id. - { label: 'runtime shadow', body: { name: 'runtime_only_set', label: 'Runtime-only set', objects: { obj_a: { allowRead: true } }, _packageId: 'sys_metadata', _provenance: 'org' }, packageId: null }, -] as const; - -/** A record that DIFFERS from the body (so a judged set would be surfaced), plus its stored definition. */ -function seedDrifted(ql: ReturnType, body: any, packageId: string | null): string { - const id = `ps_${body.name}`; - ql.permRows.push({ - id, name: body.name, managed_by: 'admin', package_id: packageId, - ...permissionSetRowFields(body), - object_permissions: JSON.stringify({ obj_z: { allowRead: true } }), - }); - ql.metaRows.push({ - id: `meta_${body.name}`, type: 'permission', name: body.name, state: 'active', organization_id: null, - package_id: packageId, metadata: JSON.stringify(body), - }); - return id; -} - -describe('[#21860] computePermissionSetDriftDiagnostics — the declared population is the lock classifier\'s', () => { - for (const shape of envShapes) { - it(`a drifted ${shape.label} with a stored definition is never diagnosed`, async () => { - const ql = makeQl([declaredSet({ _provenance: 'package' }), shape.body]); - ql.permRows.push(inSyncRow()); - seedDrifted(ql, shape.body, shape.packageId); - - const diagnostics = await computePermissionSetDriftDiagnostics(ql); - - // Only the code-shipped set is judged; the environment's own set is not. - expect(diagnostics.map((d) => ({ name: d.name, status: d.status, packageId: d.packageId }))) - .toEqual([{ name: 'ehr_quality_inspector', status: 'in_sync', packageId: 'com.example.ehr' }]); - }); - } -}); diff --git a/packages/plugins/plugin-security/src/permission-set-drift.ts b/packages/plugins/plugin-security/src/permission-set-drift.ts deleted file mode 100644 index 5beec3e4326..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-drift.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * "declared ≠ enforced" surfacing for package-declared `sys_permission_set` - * rows (field report: rc→GA upgraded envs freeze a package's permission set - * at a first-boot/overlay snapshot while the shipped artifact keeps moving — - * grants stay frozen for weeks, silently, and the only signal was a boot log - * counter). - * - * TWO INDEPENDENT MECHANISMS can make a package-declared set's ENFORCED - * grants (what `sys_permission_set` actually stores, and what the evaluator - * resolves) diverge from its shipped ARTIFACT (what `readDeclared` reads from - * the engine's SchemaRegistry, the same source `bootstrapDeclaredPermissions` - * seeds from) — and neither may be assumed to be the only one live on a given - * environment: - * - * - **`overlay_shadow`** — an active `sys_metadata` overlay row exists for - * the set's name (a Studio permission-matrix save materialized one, back - * when `permission` still allowed org overrides, or through the - * `OS_METADATA_WRITABLE` hatch since). `reconcilePermissionSetProjection` - * re-projects that overlay's content onto the record on EVERY boot - * (ADR-0094 D4 step 1, unconditionally, keyed only on overlay presence), - * so the overlay wins forever regardless of what the package ships next. - * - **`provenance_skip`** — the row's `managed_by` is not `'package'` (a - * legacy/pre-provenance insert — see `bootstrap-declared-permissions.ts`), - * so `upsertPackagePermissionSet`'s package door refuses to touch it - * (`skippedEnvAuthored`) on every boot, forever. - * - * ⚠️ THE TWO MECHANISMS CONFOUND EACH OTHER. When BOTH are present on one row - * — the exact field-report shape — `upsertEnvPermissionSet`'s `customized` - * flag (`existing.managed_by === 'package' ? !!customized : false`) is - * FORCED FALSE, because the row's `managed_by` is wrong (provenance_skip), - * even though an overlay genuinely IS shadowing it. That is measurably why - * `customized` stayed `0` on the field-reported environment — the ADR-0094 - * surface an admin would check said "not customized" while an overlay had - * frozen the set for two weeks. So detection here does NOT trust - * `managed_by` to decide whether an overlay is shadowing a row — overlay - * presence is checked directly against `sys_metadata`, independent of the - * row's (possibly wrong) provenance column, exactly so this confounded case - * is still attributed correctly (`overlay_shadow`, not silently missed). - * - * WHAT THIS MODULE DOES NOT DO: it never guesses which mechanism explains a - * mismatch — it re-derives the fact each boot from the current `sys_metadata` - * / `sys_permission_set` state, the same sources the two write-doors read. - * It also never adopts/migrates a row (out of scope by 2026-08-20 maintainer - * ruling — "新项目还没上线,不需要清理旧数据,也没有老客户升级"). - * - * ⚠️ Scope note shared with `reconcilePermissionSetProjection`: only - * ENV-WIDE overlays (`sys_metadata.organization_id IS NULL`) are considered - * — the per-organization overlay residue the projection file's header already - * documents as deliberately out of scope (#10103) stays out of scope here - * too, for the same reason (this module answers the identical "is there an - * overlay" question the reconciler already answers, from the same rows). - */ - -import { - recordDiffersFromBody, - tryFind, - tryUpdate, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { - createSeedWriteRefusals, - logSeedDurabilityFailure, -} from './write-refusals.js'; -import { readDeclared } from './bootstrap-declared-permissions.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; -import { buildExistingByName } from './seed-name-lookup.js'; - -/** - * Cause a package-declared set's enforced grants can diverge from its shipped - * artifact. `in_sync` and `other` are internal/testing values only — - * {@link persistPermissionSetDriftDiagnostics} writes `null` for `in_sync` - * (pin: an in-sync set must not be surfaced at all, not even as a quiet - * "ok" badge) and keeps `other` as a truthful catch-all for the one case - * neither named mechanism explains (a package-owned row whose own boot-seed - * write failed — `unreadable` — so its content is stale for a reason that is - * neither an overlay nor a provenance skip). - */ -export type PermissionSetDriftStatus = 'in_sync' | 'overlay_shadow' | 'provenance_skip' | 'other'; - -export interface PermissionSetDriftDiagnostic { - id: string; - name: string; - packageId: string; - status: PermissionSetDriftStatus; - /** Human-readable detail naming the grant-count mismatch and the cause. `null` when in sync. */ - detail: string | null; - /** The row's stored `drift_status` before this pass (already normalized: `'in_sync'` read back as `null`). */ - priorStatus: string | null; - priorDetail: string | null; -} - -export interface DriftDiagnosticsOptions { - logger?: ProjectionLogger; - organizationId?: string; -} - -function countGrantedObjects(objects: any): number { - return objects && typeof objects === 'object' ? Object.keys(objects).length : 0; -} - -/** True when `row` carries an ACTIVE env-wide `sys_metadata` overlay for `name` (independent of `managed_by`). */ -function buildOverlayNameSet(rows: any[]): Set { - const out = new Set(); - for (const r of rows) { - if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide overlays only — see module header - out.add(String(r.name)); - } - return out; -} - -/** - * Compute (never write) the drift diagnostic for every currently package- - * declared permission set. Pure function over the passed `ql` — a boot pass - * and a test both call this the same way; {@link persistPermissionSetDriftDiagnostics} - * is the only thing that writes. - */ -export async function computePermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise { - const out: PermissionSetDriftDiagnostic[] = []; - if (!ql || typeof ql.find !== 'function') return out; - - // [#21860] "Package-declared" is the lock's verdict for the NAME — a code - // (artifact) package ships it — read through the one classifier the write - // doors and the overlay detection reading ask. - // A package id on the item alone is not that answer: a set saved into a - // writable runtime package carries one after the first metadata list read, - // and this report used to diagnose it as package-declared. - // The per-item test that follows the verdict only picks which bodies of a - // code-shipped name are compared, exactly as before. - const codeShipped = new Map(); - const isCodeShipped = (name: string): boolean => { - let shipped = codeShipped.get(name); - if (shipped === undefined) { - shipped = classifyPackagedPermissionSet(name, ql).status === 'packaged'; - codeShipped.set(name, shipped); - } - return shipped; - }; - const declared = readDeclared(ql, 'permission').filter( - (ps) => ps?.name && (ps._packageId ?? ps.packageId) && isCodeShipped(String(ps.name)), - ); - if (declared.length === 0) return out; - - const organizationId = opts.organizationId; - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - declared.map((ps) => ps.name), - opts.logger, - organizationId, - ); - - // Same bulk overlay read `reconcilePermissionSetProjection` performs (ADR-0094 - // D4 step 1) — both `permission` and its legacy plural spelling. - const overlayRows: any[] = []; - for (const type of ['permission', 'permissions']) { - overlayRows.push(...(await tryFind(ql, 'sys_metadata', { type, state: 'active' }, 1000))); - } - const overlayNames = buildOverlayNameSet(overlayRows); - - for (const ps of declared) { - const name = String(ps.name); - const packageId = String(ps._packageId ?? ps.packageId); - const lookup = await existingByName.get(name); - if (lookup.status !== 'present') continue; // not yet materialized — nothing to diagnose (boot seeding creates it) - const row = lookup.row; - const priorStatus: string | null = row?.drift_status ?? null; - const priorDetail: string | null = row?.drift_detail ?? null; - - const differs = recordDiffersFromBody(row, ps); - if (!differs) { - out.push({ id: row.id, name, packageId, status: 'in_sync', detail: null, priorStatus, priorDetail }); - continue; - } - - const declaredCount = countGrantedObjects(ps.objects); - const enforcedCount = countGrantedObjects( - (() => { try { return JSON.parse(row.object_permissions ?? '{}'); } catch { return {}; } })(), - ); - const hasOverlay = overlayNames.has(name); - - let status: PermissionSetDriftStatus; - let detail: string; - if (hasOverlay) { - status = 'overlay_shadow'; - detail = - `An environment customization overlay (sys_metadata, type:'permission', name:'${name}') is shadowing ` + - `this package-declared set — enforced grants (${enforcedCount} object(s)) come from the overlay, not ` + - `the shipped artifact (${declaredCount} object(s)).`; - } else if (row.managed_by !== 'package') { - status = 'provenance_skip'; - detail = - `This row's managed_by ('${row.managed_by ?? 'none'}') predates package provenance tracking, so boot ` + - `sync treats it as environment-authored and never reconciles it with the package — enforced grants ` + - `(${enforcedCount} object(s)) are frozen at an old snapshot vs. the shipped artifact's ` + - `${declaredCount} object(s).`; - } else { - status = 'other'; - detail = - `Enforced grants (${enforcedCount} object(s)) differ from the shipped artifact (${declaredCount} ` + - `object(s)) for a reason that is neither an overlay nor a provenance skip — check boot logs for ` + - `'[security] declared permission sets left untouched'.`; - } - out.push({ id: row.id, name, packageId, status, detail, priorStatus, priorDetail }); - } - return out; -} - -/** - * Report, ONCE per pass, the drift-diagnostic writes the store refused. - * - * ## Why this is not {@link reportSeedWriteRefusals} - * - * The shared ACCUMULATOR (`createSeedWriteRefusals`) is reused as-is — it - * carries the shipped `isUniqueViolationError` classification and the - * value-free `code`/`errno` channel, and re-deriving either here is exactly - * the local-regex defect that module was written to retire. The shared - * REPORTER is not, and deliberately: every sentence it prints is about - * seeding the RBAC catalog — "the catalog is INCOMPLETE", "this pass's - * 'seeded' count", and a remedy naming the legacy PLATFORM-WIDE unique index - * on the catalog name column. None of that is true here. This pass seeds - * nothing; it writes two diagnostic columns onto rows that already exist, by - * id. Printing that text over this failure would send an operator to - * `os migrate` for a defect that is not there — the same "a confident wrong - * answer is worse than no answer" reasoning that makes `other` its own class - * in `reportSeedWriteRefusals` rather than a relabelled unique violation. - * - * ## Why ONE line and not one per class - * - * `reportSeedWriteRefusals` splits its classes because their REMEDIES differ. - * Here they do not: an update-by-id of two nullable diagnostic columns has no - * unique constraint to violate, so the class is a fact for `meta` (it travels - * there, per (object, class), with the driver codes) and not a reason to print - * a second sentence. - * - * ## Why the durability channel - * - * AGENTS.md "Degradation log levels", one question — after the degradation, - * does the system still look normal while something it claims is persisted - * has not landed? Yes, precisely: `drift_status` / `drift_detail` are what - * Setup's "Needs Attention" surface reads, so a refused write leaves that - * screen showing a CLEAN environment over sets that are still drifted. - * ⚠️ `check:durability-log-level` does not vouch for this choice — `ql.update` - * is not in its `DURABILITY_CRITICAL_CALLEES` vocabulary, so its green here - * means the site is outside the gate's reach (NOT MEASURED), never approval. - */ -function reportDriftWriteRefusals( - logger: ProjectionLogger | undefined, - refusals: ReturnType, - organizationId?: string, -): void { - const entries = refusals.report(); - if (entries.length === 0) return; - logSeedDurabilityFailure( - logger, - `[security] ${refusals.total} package-declared permission set drift diagnostic(s) were REFUSED by the ` + - `store — the "declared ≠ enforced" verdict this pass computed did NOT persist, so those rows keep ` + - `their PREVIOUS drift_status/drift_detail (usually none at all), Setup's "Needs Attention" surface goes ` + - `on showing them as CLEAN, and THE DEPLOYMENT WILL GO ON LOOKING HEALTHY while those sets keep ` + - `enforcing grants that differ from the shipped artifact. The drifted set NAMES are not lost with the ` + - `write: the "[security] package-declared permission set(s) enforcing grants that differ from the ` + - `shipped artifact" line is logged alongside this one and names every one of them — it is no longer ` + - `gated behind the count of writes that landed. What the store actually said is in the query engine's ` + - `"Update operation failed" entries logged just before this one, which keep the driver's own identifier ` + - `with the bound statement and its values cut. Remedy: read those entries — a sys_permission_set ` + - `missing its drift_status/drift_detail columns is a deployment SCHEMA defect, reported by ` + - `"os migrate plan" and fixed by "os migrate apply"; anything else is a store outage, which this pass ` + - `re-computes and re-attempts on the next boot. Either way nothing is lost, and nothing is recorded ` + - `either until a write lands.`, - { - refused: refusals.total, - refusals: entries, - ...(organizationId ? { organization: organizationId } : {}), - }, - ); -} - -/** - * Write the computed diagnostics onto their `sys_permission_set` rows. - * EQUALITY-GATED (#10946 discipline): a row whose stored `drift_status` / - * `drift_detail` already match is left untouched — a steady-state boot pays - * zero writes, matching the round-trip contract every other pass in this file - * keeps. - * - * `in_sync` is written as `null` — the "not surfaced" pin is a data-level - * fact, not merely a filtered view: a quiet set carries no `drift_status` - * value at all, so a client reading the raw record (not only the Setup - * "Needs Attention" view) sees nothing to worry about either. - * - * Answers `refused` alongside `updated`. ⚠️ They are not two spellings of the - * same pass: `updated` counts the verdicts that LANDED and `refused` counts - * the ones the store rejected, and a caller asking "was there drift?" while - * reading only `updated` reads a wholly refused pass as a clean one — the - * defect {@link reportDriftWriteRefusals} and the gate in - * {@link runPermissionSetDriftDiagnostics} exist to close. - */ -export async function persistPermissionSetDriftDiagnostics( - ql: any, - diagnostics: readonly PermissionSetDriftDiagnostic[], - opts: DriftDiagnosticsOptions = {}, -): Promise<{ updated: number; refused: number }> { - // ⛔ The refusal LOG is what makes a refused write distinguishable from - // "nothing to write". `tryUpdate` answers `false` for both, and this - // function's only output used to be a count of the writes that LANDED — so - // a pass whose every write was refused returned `{ updated: 0 }`, which is - // byte-identical to the steady-state boot the equality gate above is built - // to produce. See the report below for what that silence cost. - const refusals = createSeedWriteRefusals(); - let updated = 0; - for (const d of diagnostics) { - const status: string | null = d.status === 'in_sync' ? null : d.status; - const detail: string | null = d.status === 'in_sync' ? null : d.detail; - if (d.priorStatus === status && d.priorDetail === detail) continue; - if (await tryUpdate(ql, 'sys_permission_set', { id: d.id, drift_status: status, drift_detail: detail }, opts.organizationId, refusals)) { - updated += 1; - } - } - reportDriftWriteRefusals(opts.logger, refusals, opts.organizationId); - return { updated, refused: refusals.total }; -} - -/** Compute + persist in one call — what boot wiring uses. */ -export async function runPermissionSetDriftDiagnostics( - ql: any, - opts: DriftDiagnosticsOptions = {}, -): Promise<{ diagnostics: PermissionSetDriftDiagnostic[]; updated: number; refused: number }> { - const diagnostics = await computePermissionSetDriftDiagnostics(ql, opts); - const { updated, refused } = await persistPermissionSetDriftDiagnostics(ql, diagnostics, opts); - // ⛔ `updated > 0` ALONE was the suppressor. A boot on which every drift - // write is refused computes the drift correctly, persists none of it, and — - // under the old gate — printed nothing at all, which is byte-identical to a - // deployment with no drift. `refused > 0` re-opens exactly that case and - // nothing else: a steady-state boot (equality-gated, nothing to write, - // nothing refused) stays as quiet as it was. - if (updated > 0 || refused > 0) { - opts.logger?.warn?.( - '[security] package-declared permission set(s) enforcing grants that differ from the shipped artifact', - { - updated, - // Present ONLY when non-zero, so the steady-state line is unchanged - // byte-for-byte for anything reading it. Read it together with - // `updated`: `updated` counts the verdicts that LANDED, never the - // verdicts that were reached. - ...(refused > 0 ? { refused } : {}), - drifted: diagnostics.filter((d) => d.status !== 'in_sync').map((d) => ({ name: d.name, status: d.status })), - ...(opts.organizationId ? { organization: opts.organizationId } : {}), - }, - ); - } - return { diagnostics, updated, refused }; -} diff --git a/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts b/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts index 7e73aa0b7e6..b0f0817081d 100644 --- a/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-name-collision.test.ts @@ -1,41 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#17516] The set-name collision refusal must REACH THE AUTHOR. + * [#17516] The set-name collision diagnostic — the ONE derivation every door + * that refuses a foreign set name shares. * - * ## What these pins are about, and what they deliberately do not touch - * - * ⛔ Not the skip. Refusing to write into a `sys_permission_set` row another - * package owns is correct under ADR-0086 D4 and is asserted UNCHANGED in every - * case below (`skippedForeign` still counts, the foreign row is never mutated). - * The defect was that the refusal was invisible: `logger?.warn?.(…)` at that - * branch is optionally chained TWICE, so a caller passing no logger produced no - * output at all and an entire declared permission set disappeared with one - * internal counter moved. - * - * ## The measurement these replace - * - * On the pre-fix tree the first case below measured ZERO console lines across - * every channel, and `undefined` for the outcome's diagnostic records, while - * `skippedForeign` was 1. That is the card's reading, reproduced. - * - * ## The discriminating half - * - * A pin that only asserts "something was printed on a collision" passes just as - * well against a seeder that prints on EVERY seeded set, which would be a - * different defect (#12015's: a diagnostic that fires always is as unreadable as - * one that never fires). So the no-collision control asserts SILENCE on all - * five console channels, over a pass that really does seed and really does - * re-seed its own row. + * The runtime door that raised it — the declared-permission seeder's refusal to + * write into a `sys_permission_set` row another package owns — retired with the + * seeder (ADR-0131 D3: the boot writes no catalog row), and its pins went with + * it. The compile-time door (`os build` / `os validate`) still consumes the + * predicate and the wording from here, so the derivation keeps its own pins. */ import { describe, it, expect, vi } from 'vitest'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { - bootstrapDeclaredPermissions, - upsertPackagePermissionSet, -} from './bootstrap-declared-permissions.js'; import { PERMISSION_SET_NAME_COLLISION, formatPermissionSetNameCollisionDiagnostic, @@ -44,62 +21,6 @@ import { reportPermissionSetNameCollisions, } from './permission-set-name-collision.js'; -/** Minimal in-memory ql + registry for sys_permission_set seeding. */ -function makeQl(declared: any[] = []) { - const rows: any[] = []; - return { - rows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const hits = rows.filter((r) => Object.entries(where).every(([k, v]) => { - // ⛔ REFUSE what this double does not implement. A `$or` / `$and` read - // as a FIELD NAME is the silently-wrong shape: `r.$or` is `undefined`, - // no row matches, and a suite asserts on an empty result set with - // nothing erroring (`check:where-matcher`, shape (b)). The sibling - // double in `objects/reserved-identity-names.test.ts` refuses the same way. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return r[k] === v; - })); - // Hold the caller's BOUND, after the filter and by PRESENCE, so `limit: 0` - // returns nothing rather than everything. `defaultLookup` really does read - // `{ where: { name }, limit: organizationId ? 5 : 1 }` (#10103), and a - // limit-blind double cannot tell that read from an unbounded one - // (`check:objectql-double-limit`). - return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - // `assertEngineUpdateDispatch` rather than a hand-rolled id check: a fake - // looser than `ObjectQL.update` is how a dead route once shipped with its - // suite green (`check:engine-double-contract`). - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - if (object !== 'sys_permission_set') return; - if (dispatch.kind !== 'by-id') throw new Error('fake driver: only by-id update is modelled'); - const r = rows.find((x) => x.id === dispatch.id); - if (r) Object.assign(r, data); - }, - }; -} - -const declaredSet = (over: Record = {}) => ({ - name: 'crm_sales_rep', - label: 'Sales Rep', - objects: { crm_lead: { allowRead: true } }, - _packageId: 'com.example.b', - ...over, -}); - /** * Capture EVERY console channel. "Author-visible output" is not channel-specific * — the pre-fix failure was that NONE of them carried anything — so a pin that @@ -115,168 +36,6 @@ function captureConsole() { return { seen, restore: () => spies.forEach((s) => s.mockRestore()) }; } -describe('[#17516] set-name collision reaches the author', () => { - it('prints, WITH NO LOGGER INJECTED, and returns the diagnostic record', async () => { - const ql = makeQl([declaredSet()]); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - // No logger — the exact call shape under which the old branch was mute. - out = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - // ⛔ The refusal itself is UNCHANGED. - expect(out.skippedForeign).toBe(1); - expect(out.seeded).toBe(0); - expect(out.updated).toBe(0); - expect(ql.rows).toHaveLength(1); - expect(ql.rows[0].package_id).toBe('com.example.a'); - expect(ql.rows[0].object_permissions).toBe('{}'); - - // The half that was missing: the author is told, through the console, - // because no sink was injected (#10556 — silent-by-declaration rejected). - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_NAME_COLLISION); - expect(cap.seen[0]).toMatch(/^warn:/); - expect(cap.seen[0]).toContain('NOT materialized'); - - // And the RECORD, for a caller that reads no log at all. - expect(out.collisions).toHaveLength(1); - const d = out.collisions![0]!; - expect(d.event).toBe(PERMISSION_SET_NAME_COLLISION); - expect(d.severity).toBe('warning'); - expect(d.name).toBe('crm_sales_rep'); - expect(d.declaredBy).toBe('com.example.b'); - expect(d.ownedBy).toBe('com.example.a'); - // The remedy names both legal resolutions, including the ADR-0130 D1 one - // the old comment's premise denied could exist. - expect(d.fix).toContain('ADR-0130 D1'); - }); - - it('DISCRIMINATING CONTROL: a pass with no collision says nothing at all', async () => { - // Two real writes happen here — a first seed and an own-row re-seed — so - // the silence is over a pass that is doing work, not an empty one. - const ql = makeQl([declaredSet({ _packageId: 'com.example.a' })]); - - const first = captureConsole(); - let r1: Awaited>; - try { - r1 = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - first.restore(); - } - expect(r1.seeded).toBe(1); - expect(r1.skippedForeign).toBe(0); - expect(r1.collisions).toBeUndefined(); - expect(first.seen).toEqual([]); - - // Re-seed the SAME package's own row after a declaration change. - (ql as any).registry = { - listItems: () => [declaredSet({ _packageId: 'com.example.a', objects: { crm_lead: { allowRead: true, allowCreate: true } } })], - }; - const second = captureConsole(); - let r2: Awaited>; - try { - r2 = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - second.restore(); - } - expect(r2.updated).toBe(1); - expect(r2.skippedForeign).toBe(0); - expect(r2.collisions).toBeUndefined(); - expect(second.seen).toEqual([]); - }); - - it('reports through an INJECTED sink instead of the console when one is given', async () => { - const ql = makeQl([declaredSet()]); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const warned: Array<{ m: string; meta?: Record }> = []; - const cap = captureConsole(); - try { - await bootstrapDeclaredPermissions(ql, undefined, { - logger: { warn: (m, meta) => { warned.push({ m, meta }); } }, - }); - } finally { - cap.restore(); - } - - const collision = warned.filter((w) => w.m.includes(PERMISSION_SET_NAME_COLLISION)); - expect(collision).toHaveLength(1); - expect(collision[0]!.meta?.collisions).toEqual([ - expect.objectContaining({ name: 'crm_sales_rep', declaredBy: 'com.example.b', ownedBy: 'com.example.a' }), - ]); - // The host sink REPLACES the console default — not both. - expect(cap.seen).toEqual([]); - }); - - it('the ADR-0086 P2 single-set path reports too — it has no pass to summarise', async () => { - const ql = makeQl(); - ql.rows.push({ - id: 'ps_owned_by_a', - name: 'crm_sales_rep', - managed_by: 'package', - package_id: 'com.example.a', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - // No logger, no collector — the publish materializer's exact call shape. - out = await upsertPackagePermissionSet(ql, declaredSet(), 'com.example.b'); - } finally { - cap.restore(); - } - - expect(out.skippedForeign).toBe(1); - expect(out.collisions).toHaveLength(1); - expect(cap.seen).toHaveLength(1); - expect(cap.seen[0]).toContain(PERMISSION_SET_NAME_COLLISION); - }); - - it('a package-managed row with NO package_id is foreign, not adoptable', async () => { - const ql = makeQl([declaredSet()]); - // ADR-0086 D3's exact ambiguity: managed_by 'package', owner unprovable. - ql.rows.push({ - id: 'ps_unowned', - name: 'crm_sales_rep', - managed_by: 'package', - object_permissions: '{}', - }); - - const cap = captureConsole(); - let out: Awaited>; - try { - out = await bootstrapDeclaredPermissions(ql, undefined); - } finally { - cap.restore(); - } - - expect(out.skippedForeign).toBe(1); - expect(out.updated).toBe(0); - expect(ql.rows[0].object_permissions).toBe('{}'); - expect(out.collisions![0]!.ownedBy).toBeNull(); - expect(cap.seen).toHaveLength(1); - }); -}); - describe('[#17516] the shared derivation both doors must use', () => { it('permissionSetNameIsForeign: same owner is ours, anything else is foreign', () => { expect(permissionSetNameIsForeign('com.example.a', 'com.example.a')).toBe(false); diff --git a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts index 973e700e495..813dc8929e9 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -21,8 +21,9 @@ * ⛔ not a uniform sweep. The pins below moved accordingly, and this diff is the * record of the ruling. What each seam now says: * - * - `normalize-managed-by.ts` — a read fault REFUSES the pass for that batch - * and reports at `error`; it never answers "already canonical". + * - `normalize-managed-by.ts` refused a read fault for its batch; its + * reading 1 retired with the module (ADR-0131 D3: no boot pass rewrites + * catalog rows any more). * - `auto-org-admin-grant.ts` — a fault on the `sys_member` read that feeds * the revoke branch SKIPS that user for the round, never enters the revoke * branch, and reports at `error`. @@ -57,7 +58,6 @@ import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.j import { reconcileOrgAdminGrant, backfillOrgAdminGrants } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; const SYS = { context: { isSystem: true } } as any; const ORG = 'org_a'; @@ -148,133 +148,6 @@ function recordingLogger() { }; } -// ─────────────────────────────────────────────────────────────────────────── -// Reading 1 — normalize-managed-by.ts `tryFind` (`catch { return []; }`) -// ─────────────────────────────────────────────────────────────────────────── - -describe('[#15840] reading 1 — normalize-managed-by tryFind', () => { - it('a control: a legacy row IS healed, so the harness can tell a real difference', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - const counts = await normalizeManagedByVocab(engine, { logger: logger as any }); - - expect(counts).toEqual({ positions: 1, permissionSets: 0 }); - expect(lines.filter((l) => l.startsWith('info:')).length).toBe(1); - }, 120_000); - - it('[RULED] an unreadable catalog REFUSES the pass; an already-canonical one still answers', async () => { - // ⭐ THE POSITIVE CONTROL — the healthy arm: a catalog that really is - // already canonical. Its behaviour must be EXACTLY what it was before the - // ruling, or this change is option C wearing option A's name. - const healthyEngine = await boot(); - const healthy = recordingLogger(); - const healthyCounts = await normalizeManagedByVocab(healthyEngine, { logger: healthy.logger as any }); - expect(healthyCounts).toEqual({ positions: 0, permissionSets: 0 }); - expect(healthy.lines).toEqual([]); - - // The faulted arm: a catalog holding a row that DOES need healing, behind a - // read that cannot answer. The row is the discriminator — it makes the two - // arms differ in the world, so anything that still reports them alike is - // reporting a state it did not read. - const faultedEngine = await boot(); - await (faultedEngine as any).insert( - 'sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS, - ); - let faultsFired = 0; - const faulted = recordingLogger(); - await expect( - normalizeManagedByVocab( - withFault(faultedEngine, { find: async () => { faultsFired += 1; throw READ_FAULT(); } }), - { logger: faulted.logger as any }, - ), - ).rejects.toThrow(/managed_by normalize REFUSED for sys_position/); - - // ⭐ The refusal aborts at the FIRST un-answered read. Before the ruling - // this pass swallowed four of them (once per legacy value) and reported - // nothing; it now asks once, refuses, and says so once. - expect(faultsFired).toBe(1); - // …and the row it was supposed to heal is untouched. - const stillLegacy = await (faultedEngine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(stillLegacy[0].managed_by).toBe('system'); - - // The value channel separates them: one answers, the other refuses. - // The report channel separates them too — exactly one line, at `error`. - const errors = faulted.lines.filter((l) => l.startsWith('error:')); - expect(errors.length).toBe(1); - expect(errors[0]).toContain('cannot tell "already canonical" from "could not ask"'); - expect(errors[0]).toContain('injected read fault'); - expect(errors[0]).toContain('"healedBeforeRefusal":0'); - // ⛔ And it never claims the counts an already-canonical catalog reports. - expect(faulted.lines.some((l) => l.includes('managed_by vocab normalized'))).toBe(false); - }, 120_000); - - it('[RULED] the refusal reaches the consumer catch that was declared for it', async () => { - // `security-plugin.ts` wraps this call in `try { … } catch { logger.warn( - // '[security] managed_by vocab normalization failed (non-fatal)') }` — the - // only handling any consumer declares, and the reason a refusal is decidable - // at this seam at all. Before the ruling nothing reached it, because the - // fault was swallowed one frame below. Now it does, and boot still proceeds. - const engine = await boot(); - await expect( - normalizeManagedByVocab(withFault(engine, { find: async () => { throw READ_FAULT(); } }), {}), - ).rejects.toThrow(/the catalog read did not answer/); - }, 120_000); - - it('[RULED] a NON-ARRAY answer refuses too — an envelope is not an empty catalog', async () => { - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - await expect( - normalizeManagedByVocab( - withFault(engine, { find: async (o: string, q?: any, opt?: any) => ({ records: await engine.find(o, q, opt) }) }), - { logger: logger as any }, - ), - ).rejects.toThrow(/did not answer with a row array/); - - expect(lines.filter((l) => l.startsWith('error:')).length).toBe(1); - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('system'); - }, 120_000); - - it('[RULED] rows healed BEFORE the refusal stay healed, and the report says how many', async () => { - // The fault lands on the second object, after the first one healed. A - // refusal is not a rollback, and the `error` line has to say so or an - // operator reads it as "nothing happened". - const engine = await boot(); - await (engine as any).insert('sys_position', { name: 'pos_legacy', label: 'Legacy', managed_by: 'system' }, SYS); - const { lines, logger } = recordingLogger(); - - await expect( - normalizeManagedByVocab( - withFault(engine, { - find: async (o: string, q?: any, opt?: any) => { - if (o === 'sys_permission_set') throw READ_FAULT(); - return engine.find(o, q, opt); - }, - }), - { logger: logger as any }, - ), - ).rejects.toThrow(/REFUSED for sys_permission_set/); - - // The position row WAS healed on the way, and stays healed. - const rows = await (engine as any).find('sys_position', { where: { name: 'pos_legacy' } }, SYS); - expect(rows[0].managed_by).toBe('platform'); - const errors = lines.filter((l) => l.startsWith('error:')); - expect(errors.length).toBe(1); - expect(errors[0]).toContain('the next boot asks again'); - }, 120_000); - - it('⭐ POSITIVE CONTROL — an engine with no find/update is NOT a read fault and still answers', async () => { - // The `!ql` guard is not a read that failed; it is a caller with no engine. - // Byte-identical to before the ruling. - await expect(normalizeManagedByVocab(null as any)).resolves.toEqual({ positions: 0, permissionSets: 0 }); - await expect(normalizeManagedByVocab({} as any)).resolves.toEqual({ positions: 0, permissionSets: 0 }); - }); -}); - // ─────────────────────────────────────────────────────────────────────────── // Reading 2 — auto-org-admin-grant.ts `tryFind` (`catch` reports at `debug`) // ─────────────────────────────────────────────────────────────────────────── diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12449d326a2..fbc0628f1e2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -57,14 +57,12 @@ import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-che import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { createPermissionSetWriteThrough, registerPermissionSetProjection, reconcilePermissionSetProjection, } from './permission-set-projection.js'; -import { runPermissionSetDriftDiagnostics } from './permission-set-drift.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; @@ -78,12 +76,7 @@ import { type SuggestionListFilter, } from './suggested-audience-bindings.js'; import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; -import { - catalogIsPerOrganization, - listSeedOrganizationIds, -} from './organization-scope.js'; -import { normalizeManagedByVocab } from './normalize-managed-by.js'; -import { readDeclaredCapabilityContext } from './declared-capability-context.js'; +import { readDeclared, readDeclaredCapabilityContext } from './declared-capability-context.js'; import { RLSCompiler, RLS_DENY_FILTER, compiledPolicyNameOf, policyDeclaresClause } from './rls-compiler.js'; import { computeTenantLayer0Verdict, @@ -587,12 +580,11 @@ interface CbpRelation { * • `platform` / `package` are managed; `admin`/∅ (tenant-authored) rows are * the admin's. * • sys_position additionally keeps its LEGACY values `system` (→ platform) - * and `config` (→ package) in the managed map: the boot normalizer - * (normalize-managed-by.ts) heals stored rows to the canonical vocabulary, - * but rows written before the normalizer runs — or in a store it has not - * touched yet — must not lose protection in the interim. Dropping the - * legacy keys here is what silently disarmed this gate for sys_position - * after the A4 rename (#2926 ①). + * and `config` (→ package) in the managed map: no boot pass heals stored + * rows to the canonical vocabulary any more (ADR-0131 retired it with the + * catalog seeders), so a row still carrying a legacy value keeps its + * protection here. Dropping the legacy keys is what silently disarmed this + * gate for sys_position after the A4 rename (#2926 ①). * The map value for each managed `managed_by` is the human owner label used in * the (business-message-only) deny text. */ @@ -4398,9 +4390,9 @@ export class SecurityPlugin implements Plugin { ); // Defer platform admin bootstrap until all plugins finish starting — - // sys_user / sys_permission_set objects must be registered (by - // plugin-auth and platform-objects respectively) before we can - // insert seed rows. Falls back to immediate execution when the + // sys_user / sys_user_permission_set objects must be registered (by + // plugin-auth and platform-objects respectively) before the promotion + // can write its grant. Falls back to immediate execution when the // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); @@ -4410,81 +4402,19 @@ export class SecurityPlugin implements Plugin { // onMetadataMutation fallback appends listeners, and re-wiring that would // project each save N times. let envProjectionWired = false; + // [#22801 → A] Same once-guard for the `permission` mutation listener + // that re-derives `everyone` (registered in `runBootstrap`). + let everyoneMutationWired = false; // [#3325 / ADR-0092] Union the better-auth managed-object write denies into // the default sets from the LIVE registry, replacing the hand-maintained // baseline as the source of truth (a newly-declared identity table is then // covered without editing a list). This mutates the shared // `bootstrapPermissionSets` instances IN PLACE — the same objects the - // permission evaluator resolves and `bootstrapPlatformAdmin` serializes into - // the seed row — so it must run BEFORE the seeder below, and once (the - // transform is idempotent, but the once-flag avoids re-reading the registry / - // re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. + // permission evaluator resolves — so it runs once, before the bootstrap + // below (the transform is idempotent, but the once-flag avoids re-reading + // the registry / re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. let managedDeniesApplied = false; - // ── The RBAC catalog, materialized PER ORGANIZATION ────────────────── - // - // `sys_position` and `sys_permission_set` both spell their name index - // `unique: 'organization'`, and both were seeded organization-less. On a - // walled deployment that made the whole catalog unreadable to EVERY - // principal at every rung: this plugin's Layer 0 composes a strict - // `organization_id = :tenant` and the middleware ANDs it over the driver's - // `(organization_id = :tenant OR organization_id IS NULL)`, and the - // conjunction of the two is the strict equality alone. Nothing about the - // wall changes here, at either layer — the rows get an owner instead. - // - // The catalog step is named ONCE and driven from two places (the boot sweep - // below, and the organization-creation hook further down) so a newly - // created organization can never be seeded differently from one present at - // boot. [ADR-0131 D2/D3] Positions get no row from the boot: the six - // built-ins are declared by this plugin (`builtin-positions.ts`), a - // package's positions are registry items, and the `everyone` baseline is - // that anchor's declared `permissionSets` (`declareEveryoneBaseline`). See `organization-scope.ts` for the doctrine and the loud guard - // that stands in place of a reap, and `rowMatchesDeclaration` for why the - // boot sweep is O(changed declarations) rather than O(organizations x rows) - // of blind writes. - // - // `organizationId` undefined is the `single`-posture pass and reproduces - // the pre-existing organization-less behaviour exactly. - // [#11532] The names `bootstrapPlatformAdmin` seeds organization-less on - // every boot. The per-organization pass sees those rows through the driver's - // compatibility arm and used to report them as PRE-FIX leftovers with a - // remedy ("re-initialize the deployment") that mints them again — on a fresh - // walled deployment the whole line was false and its first branch was a - // loop. Handed over from here because this is where the one array lives: - // the same `bootstrapPermissionSets` goes to the platform bootstrap above - // and onto the manifest as `permissions`, so the two can never disagree. - const platformBucketNames = this.bootstrapPermissionSets - .map((p) => p.name) - .filter((n): n is string => typeof n === 'string' && n !== ''); - const seedCatalogPermissions = async (organizationId?: string): Promise => { - try { - await bootstrapDeclaredPermissions(ql, this.metadata, { logger: ctx.logger, organizationId, platformBucketNames }); - } catch (e) { - ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message, organization: organizationId }); - } - }; - /** - * Every catalog step for ONE organization, in boot order. Used by the - * organization-creation hook; the boot sweep drives the same step - * interleaved with the organization-independent bootstrap work. - */ - const seedCatalogForOrganization = async (organizationId?: string): Promise => { - await seedCatalogPermissions(organizationId); - }; - /** - * Which passes this boot performs: one per organization under a walled - * posture, exactly one organization-less pass under `single`, and NONE when - * the organization enumeration failed (already warned — an unreadable - * `sys_organization` is "we do not know", never "there are no tenants"). - * A walled deployment with zero organizations yet is also none; each - * organization is seeded as it is created. - */ - const catalogSeedPasses = async (): Promise> => { - if (!catalogIsPerOrganization(this.tenancyPosture)) return [undefined]; - const ids = await listSeedOrganizationIds(ql, ctx.logger); - return ids ?? []; - }; - const runBootstrap = async () => { try { if (!managedDeniesApplied) { @@ -4512,79 +4442,33 @@ export class SecurityPlugin implements Plugin { // real answer: a later pass that returns none (walled posture, an // unreadable engine) must not erase the admin an earlier pass resolved. if (report?.adminUserId) this.claimTargetAdminUserId = report.adminUserId; - // Which organizations this boot seeds. Resolved ONCE per bootstrap run. - const catalogPasses = await catalogSeedPasses(); - // [ADR-0086 D5] Seed stack-declared permission sets into - // sys_permission_set with package provenance (managed_by:'package' + - // package_id) — packages ship working default access for their own - // objects, and the admin surface finally sees them. Runs AFTER - // bootstrapPlatformAdmin so the platform defaults keep their - // insert-once, provenance-less shape (env config, never clobbered). - for (const organizationId of catalogPasses) { - await seedCatalogPermissions(organizationId); - } - - // [ADR-0086 P2 — 块1] Register the publish-time materializer so a - // permission set authored/edited through the PACKAGE door (saved as a - // `permission` draft, then published) lands in sys_permission_set with - // managed_by:'package' + package_id — the exact provenance the boot - // seeder stamps, only now on the runtime publish path instead of only at - // boot. Idempotent: registerPublishMaterializer replaces on re-run, and - // upsertPackagePermissionSet refuses to clobber env- or foreign-owned - // rows (ADR-0086 D4), so the two doors never overwrite each other. try { const protocol: any = ctx.getService?.('protocol'); - if (protocol && typeof protocol.registerPublishMaterializer === 'function') { - protocol.registerPublishMaterializer( - 'permission', - async (args: { body: unknown; packageId: string | null; organizationId: string | null }) => { - const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger); - // [#10946] `unchanged` counts here. The question this line asks - // is "did the record end up matching the published body", NOT - // "was a write issued" — and until the seeder stopped re-writing - // rows it had nothing to change, those two were accidentally the - // same number. A re-publish of an identical body now lands in - // `unchanged`, and reading only `seeded + updated` would report - // that converged publish as a FAILURE and skip the audience - // reconcile below. The three refusal branches (foreign package, - // env-authored name, no owning package) all leave `unchanged` at - // 0, so every case that reported 0 before still reports 0. - const applied = r.seeded + r.updated + r.unchanged; - // [ADR-0090 D5] A published set carrying the install-time - // suggestion flag surfaces (or retires) its pending - // suggestion row right away — same convergent sync as boot — - // and `everyone` is derived again, so an accepted set whose new - // body fails the derivation stops granting at once. - if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) { - try { - await reconcileAudienceBindingSuggestions(ql, ctx.logger); - } catch (e) { - ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); - } - await this.declareEveryone(ctx.logger); + // [ADR-0090 D5, #22801 → A] Every landed `permission` mutation — + // publish, direct-active save or delete, through any door — runs the + // suggestion reconcile and derives `everyone` again, so an accepted + // set whose new body no longer qualifies (dropped `isDefault`, a + // forbidden bit, deleted) stops granting at once rather than at the + // next boot. Unconditional on the body: whether a set still qualifies + // is `readAcceptedEveryoneSets`' question, asked of what the catalog + // now serves. Drafts change nothing the catalog serves. Wired once; + // the listener is fire-and-forget, so it owns its failures, and + // `declareEveryone` never throws. + if (!everyoneMutationWired && protocol && typeof protocol.onMetadataMutation === 'function') { + protocol.onMetadataMutation((evt: { type?: string; state?: string }) => { + if (evt?.type !== 'permission' || evt.state === 'draft') return; + void (async () => { + try { + await reconcileAudienceBindingSuggestions(ql, ctx.logger); + } catch (e) { + ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // A publish that materialized nothing did NOT go live — report it - // as a failure with the reason so the package-door UI never shows - // a clean publish over a set the admin surface can't see (ADR-0049 - // honesty). The upsert only lands zero rows when it refused: the - // name is owned by another package, owned by the env door, or the - // publish carried no owning package_id to stamp. - if (applied === 0) { - return { - success: false, inserted: 0, updated: 0, - error: r.skippedForeign > 0 - ? 'permission set name is owned by another package' - : r.skippedEnvAuthored > 0 - ? 'permission set name is owned by the environment (edit it through the admin door)' - : 'permission set was not materialized (publish carried no owning package)', - }; - } - return { success: true, inserted: r.seeded, updated: r.updated }; - }, - ); + await this.declareEveryone(ctx.logger); + })(); + }); + everyoneMutationWired = true; } - // [#2747] Uninstall counterpart of the materializer above: when the - // owning package is uninstalled, revoke its data-plane permission + // [#2747] When the owning package is uninstalled, revoke its data-plane permission // rows (package-owned sets + their position/user bindings + the // package's suggestion rows) so grants die with the package — the // "no ghost grants" clause of ADR-0090 D5. @@ -4644,18 +4528,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); } - // [field report — rc→GA declared≠enforced surfacing] Recompute - // "declared ≠ enforced" per package-declared set AFTER both doors - // have settled for this boot (the package door above, and the env - // door's reconciliation just above this line) — so `drift_status` - // reflects the boot's TRUE final state, not a mid-boot snapshot. - // Own try/catch: a failure here must never take down boot, and must - // never be read as "reconciliation also failed" in the log above. - try { - await runPermissionSetDriftDiagnostics(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] permission-set drift diagnostics failed', { error: (e as Error).message }); - } // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce // 式)」, item 3] The DETECTION READING for sets that were already // silently forked before the save door was locked: count + NAMES, @@ -4665,22 +4537,16 @@ export class SecurityPlugin implements Plugin { // cold-boot check holding such a fork does not boot at all; its // remedy is the offline `os migrate security-catalog-overlays`. // - // Distinct from the drift diagnostic above and deliberately run - // beside it: drift reports a set whose grants ALREADY differ from - // the artifact, so an overlay that currently happens to match is - // invisible to it — while that fork is entirely real and freezes - // the set the moment the package ships its next version. - // // Own try/catch, same discipline as its neighbours: a failure here - // must never take down boot, and must never read as "the drift - // diagnostics also failed". + // must never take down boot, and must never read as "the + // reconciliation also failed". try { await reportPackagedPermissionSetOverlays(ql, { logger: ctx.logger }); } catch (e) { ctx.logger.warn('[security] packaged permission-set overlay detection failed', { error: (e as Error).message }); } } catch (e) { - ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message }); + ctx.logger.warn('[security] permission-set door registration failed', { error: (e as Error).message }); } // [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface: // every declared `isDefault: true` set the `everyone` definition does @@ -4692,15 +4558,6 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped - // 'user'; older positions stamped system/config/user) onto the unified - // platform/package/admin vocab. Runs after the seeders so their canonical - // writes land first; idempotent and non-fatal. - try { - await normalizeManagedByVocab(ql, { logger: ctx.logger }); - } catch (e) { - ctx.logger.warn('[security] managed_by vocab normalization failed (non-fatal)', { error: (e as Error).message }); - } bootstrapRanOnce = true; ctx.logger.info('[security] platform bootstrap complete', report); return report; @@ -4718,8 +4575,8 @@ export class SecurityPlugin implements Plugin { // [ADR-0131 D4] Name the permission set on every grant written before // `sys_user_permission_set.permission_set` existed — once per deployment, // recorded in `sys_migration`. At `kernel:bootstrapped`, not `kernel:ready`: - // every `kernel:ready` handler has settled by then (the bootstrap above that - // seeds the catalog rows among them), so the security catalog read each + // every `kernel:ready` handler has settled by then (the bootstrap above + // among them), so the security catalog read each // name is verified through sees every definition this boot registers. A // name it still does not resolve leaves the verdict unrecorded, so the next // boot tries again. See `grant-permission-set-name-backfill.ts`. @@ -4802,45 +4659,6 @@ export class SecurityPlugin implements Plugin { }); } - // ── Project the permission sets of a package that arrives AFTER the boot ── - // - // [#21322, ADR-0086 D5 — a package's sets are seeded ON INSTALL] The - // declared-permission seeding above runs once, at - // `kernel:ready`, over whatever the engine registry holds by then. A package - // registered later — `os package install` into a running runtime (the - // install-local plugin), an artifact reload — was never projected: its sets - // resolved for the evaluator (`/meta/permission` listed them) while - // `sys_permission_set` had no row, so no admin could grant them until a - // restart re-ran this pass. Every such door announces `metadata:reloaded`, - // the platform's one post-boot re-sync signal (the automation engine - // re-binds flows off the same event), so re-run the SAME step here: the - // same function, the same organization passes, the same provenance rules. - // Idempotent and upgrade-aware by construction (it re-seeds only rows it - // owns and never clobbers env-authored ones), so a reload that changed no - // permission set writes nothing. - // - // Only once the boot's own pass has run: it is what lets - // `bootstrapPlatformAdmin` write the platform defaults first, in their - // insert-once shape, and a reload cannot arrive ahead of it on a real - // kernel anyway. Never throws — `trigger` dispatch PROPAGATES, and a - // subscriber failure must not fail the install or publish that announced. - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('metadata:reloaded', async () => { - if (!bootstrapRanOnce) return; - try { - for (const organizationId of await catalogSeedPasses()) { - await seedCatalogPermissions(organizationId); - } - } catch (e) { - ctx.logger.warn( - '[security] declared permission sets were NOT re-projected after a metadata reload — a package ' + - 'registered after boot has no sys_permission_set row until the next restart', - { error: (e as Error).message }, - ); - } - }); - } - // ── Re-run the seed-ownership CLAIM when the seed actually settles ──────── // // Subscribed in `init()`, not here — see {@link claimSeedOwnershipOnSettle}. @@ -4967,80 +4785,12 @@ export class SecurityPlugin implements Plugin { void runOrgAdminBackfill(); } - // ── Seed the RBAC catalog for a NEWLY CREATED organization ────────── - // - // The boot sweep covers the organizations that exist when the kernel is - // ready. Every organization minted afterwards needs the same catalog, and - // waiting for the next restart is not a repair: until it runs, that - // tenant's Setup lists no positions, no permission sets and no sharing - // rules — the exact symptom per-organization materialization exists to fix, - // only scoped to the newest tenant. Steady state therefore rides THIS hook, - // which is also why the boot sweep can afford to be a read-mostly - // reconciliation rather than a blind rewrite. - // - // Walled postures only: under `single` there is no organization to seed - // for, and the one organization-less pass at boot is the correct shape. - // Idempotent by construction (every seeder upserts by - // `(name, organization_id)`), so a double-fire is harmless. - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - await next(); - if (opCtx?.object !== 'sys_organization') return; - const op = opCtx?.operation; - if (op !== 'insert' && op !== 'create') return; - if (!catalogIsPerOrganization(this.tenancyPosture)) return; - const organizationId = this.extractOrganizationIdFromWrite(opCtx); - if (!organizationId) { - // Never fall back to an organization-less pass here. That is the - // `single`-posture shape, and running it on a walled deployment would - // mint exactly the invalid-state row this change exists to remove. - ctx.logger.warn?.( - '[security] an organization was created but its id could not be read off the write — its ' + - 'RBAC catalog was NOT seeded now; the next boot sweep covers it', - { object: 'sys_organization' }, - ); - return; - } - try { - await seedCatalogForOrganization(organizationId); - ctx.logger.info?.('[security] RBAC catalog seeded for a newly created organization', { - organization: organizationId, - }); - } catch (e) { - ctx.logger.warn?.('[security] catalog seeding for a new organization failed (retried on next boot)', { - organization: organizationId, error: (e as Error).message, - }); - } - }); - // Per-organization seed DATA replay on `sys_organization` insert (business // records) lives in `@objectstack/organizations`, along with // `claimOrphanOrgRows` / `cloneOrgSeedData`. Install that plugin for - // multi-tenant deployments. The hook above is a different thing: the RBAC - // CATALOG, which plugin-security owns and seeds from declarations. - } - - /** - * The id of the organization a `sys_organization` insert just created. - * - * Read from the write's own result first and its input second, because the - * two disagree in the case that matters: an insert that lets the engine mint - * the id carries none on the way in. Returns `undefined` rather than guessing - * — the caller refuses to run an organization-less pass on a walled - * deployment, which is the whole point. - */ - private extractOrganizationIdFromWrite(opCtx: any): string | undefined { - const candidates = [ - opCtx?.result?.id, - Array.isArray(opCtx?.result) ? opCtx.result[0]?.id : undefined, - opCtx?.data?.id, - opCtx?.doc?.id, - opCtx?.record?.id, - opCtx?.id, - ]; - for (const c of candidates) { - if (typeof c === 'string' && c !== '') return c; - } - return undefined; + // multi-tenant deployments. The RBAC catalog needs no such replay: this + // plugin writes no catalog row, and every organization reads the same + // registry (ADR-0131 D3). } /** @@ -7058,7 +6808,7 @@ export class SecurityPlugin implements Plugin { * overlay layer. Fails CLOSED and never depends on the caller's grants, so * a platform admin with `modifyAllRecords` cannot forge provenance either. * System/boot writes never reach here (the middleware short-circuits on - * `isSystem`), so the seeder and the publish materializer are unaffected. + * `isSystem`), so system writes are unaffected. */ /** * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index d912893a03e..3a7c2f33cf5 100644 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts @@ -17,8 +17,8 @@ * because the mute was never on the injected arm. * * So case 1 runs a **lit control on the same subject in the same run** — - * `reportCapabilityRowsUnreadable`, an #18091 site that already reaches the - * console with no sink. A zero from this site is a reading only while that + * `reportThroughSink` with no sink, the one derivation every repaired site + * reaches the console through. A zero from this site is a reading only while that * control fires; if the harness ever stops capturing `console.warn`, the * control goes to zero too and the case fails instead of passing vacuously. * @@ -43,7 +43,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildExistingByName } from './seed-name-lookup.js'; -import { reportCapabilityRowsUnreadable } from './seed-refusal-diagnostics.js'; +import { reportThroughSink } from './seed-refusal-sink.js'; /** A driver seam that cannot answer at all — the outage arm. */ function unreadableQl() { @@ -102,15 +102,15 @@ describe('[#18570] batched seed existence read — its own failure is audible wi await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha', 'beta']); // THE LIT CONTROL, same run, same subject (author-visible output with no - // sink injected): an #18091 site that already reaches the console. - reportCapabilityRowsUnreadable(undefined, { unreadable: 2, total: 2 }); + // sink injected): the derivation every repaired site reaches the console through. + reportThroughSink(undefined, '[control] lit control', { unreadable: 2, total: 2 }); }); const lines = seen.map((args) => String(args[0])); // The control fires — so a zero from the site under repair would be a // reading rather than a dead harness. - expect(lines.filter((l) => l.includes('capability_rows_unreadable'))).toHaveLength(1); + expect(lines.filter((l) => l.includes('[control] lit control'))).toHaveLength(1); // And the site under repair is no longer the silent one. Pre-#18570 this // run produced the control line ALONE. diff --git a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts b/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts deleted file mode 100644 index ca6a3fe2a59..00000000000 --- a/packages/plugins/plugin-security/src/seed-refusal-diagnostics.ts +++ /dev/null @@ -1,393 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18091] The five remaining refusal diagnostics of the two declared-metadata - * seeders — one wording, one token and one record PER SITE. - * - * ## What was wrong - * - * `bootstrapDeclaredCapabilities` and `bootstrapDeclaredPermissions` refuse to - * write in five more places, and every one of those refusals was spelled - * `logger?.warn?.(…)` — optionally chained TWICE, so a caller that injected no - * logger got NO OUTPUT AT ALL. Measured on the pre-fix tree, each site driven - * with no logger while all five console channels were spied: - * - * ```text - * curated platform capability refused skippedPlatform = 1 lines = 0 - * capability declaration unowned skippedUnowned = 1 lines = 0 - * capability rows unreadable unreadable = 1 lines = 0 - * permission set declaration unowned (no counter) lines = 0 - * permission set rows unreadable unreadable = 1 lines = 0 - * LIT CONTROL capability_name_collision skippedForeign = 1 lines = 1 - * LIT CONTROL permission_set_name_… skippedForeign = 1 lines = 1 - * ``` - * - * The two lit controls are the already-repaired axes in the SAME harness, so - * the zeros are a reading rather than an artefact of the probe. - * - * ⛔ Not one of the skips changes here. They are correct under ADR-0086 D4 (a - * package never writes into a foreign record) and ADR-0086 D3 (a row that - * cannot prove its owner makes uninstall undefined). The defect is only that - * the refusal never reached the author who caused it. - * - * ## Why five diagnostics and not one - * - * #18023's delivery established the rule and it is applied here rather than - * re-argued: the token, the record, the wording and the CONSEQUENCE are - * site-specific — four of the six parts a refusal report is made of. The five - * consequences below are genuinely different facts about the deployment: - * - * - a curated-platform-name hijack still RESOLVES (the curated pass owns the - * row) and loses only the declaring package's authored metadata; - * - an unowned CAPABILITY declaration may resolve, may fall back to the - * back-compat derived placeholder, or may exist nowhere at all — three - * outcomes, decided by what already stands in `sys_capability`; - * - an unowned PERMISSION SET keeps every grant working (the evaluator - * resolves declared sets through the metadata registry) and loses only the - * RECORD — the Setup surface, the provenance axis and uninstall; - * - an unreadable read wrote nothing and compared nothing, so nothing is lost - * and nothing arrived either — and what "nothing arrived" costs differs - * again between the two tables. - * - * Wording them into one generic "declaration skipped" sentence would send every - * author of the first kind hunting for a broken grant that is not broken. - * - * ## What IS shared - * - * Exactly one thing: WHERE the line goes. {@link reportThroughSink} carries the - * delivery rule for all five, so this card adds one derivation rather than five - * more copies of the two lines #17516 and #18023 each wrote out by hand. - * - * ## Why `event` and not `code` - * - * Every token below is a snake_case DATA VALUE, not an ADR-0112 error code: it - * is never routed to `error.code`, never reaches a wire refusal, and travels as - * the structured half of a warning about an artifact — the same discrimination - * `CAPABILITY_NAME_COLLISION` and `PERMISSION_SET_NAME_COLLISION` already make. - */ - -import { reportThroughSink, type CollisionReportSink } from './seed-refusal-sink.js'; - -export type { CollisionReportSink }; - -// ─────────────────────────────────────────────────────────────────────────── -// Tokens — one grep string per refusal kind. -// ─────────────────────────────────────────────────────────────────────────── - -/** A package declared a capability whose name is a CURATED platform capability. */ -export const CAPABILITY_PLATFORM_NAME_REFUSED = 'capability_platform_name_refused'; - -/** A declared capability carries no resolvable owning package. */ -export const CAPABILITY_DECLARATION_UNOWNED = 'capability_declaration_unowned'; - -/** Declared capabilities left untouched because `sys_capability` could not be read. */ -export const CAPABILITY_ROWS_UNREADABLE = 'capability_rows_unreadable'; - -/** A declared permission set carries no resolvable owning package. */ -export const PERMISSION_SET_DECLARATION_UNOWNED = 'permission_set_declaration_unowned'; - -/** Declared sets left untouched because `sys_permission_set` could not be read. */ -export const PERMISSION_SET_ROWS_UNREADABLE = 'permission_set_rows_unreadable'; - -/** - * Severity is `warning` on all five for the #4632 reason: this is FUNCTIONAL - * degradation, never a durability failure. Nothing claimed to be persisted - * silently failed to land. - */ -type RefusalSeverity = 'warning'; - -/** The shape every diagnostic in this module shares — ⛔ the wording is not part of it. */ -interface SeedRefusalDiagnosticBase { - readonly severity: RefusalSeverity; - readonly message: string; - readonly fix: string; -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 1 — a package declares a CURATED platform capability name. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused because its name is platform-curated. */ -export interface CapabilityPlatformNameRefusedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_PLATFORM_NAME_REFUSED; - /** The declared capability's name — a member of `PLATFORM_CAPABILITY_NAMES`. */ - readonly name: string; - /** - * The package whose declaration was dropped. `null` when the declaration - * carries no resolvable owner either: this refusal is decided BEFORE the - * owner check, so both faults can be true of one declaration and printing - * `undefined` at an author is not an option. - */ - readonly declaredBy: string | null; - /** The bootstrap permission set(s) granting it — the blast radius, named. */ - readonly grantedBy: readonly string[]; -} - -/** - * Word the curated-name refusal. - * - * ⚠️ The consequence differs from a foreign-owner collision even though both - * end in "the declaration was not applied": the curated pass of - * `bootstrapSystemCapabilities` seeds every curated name UNCONDITIONALLY, so - * the row is there whatever this seeder decides. Nothing is denied. What the - * author loses is the authored label/description/scope and the ADR-0086 D3 - * provenance claim — and the remedy is not "co-own the namespace" (the - * ADR-0130 D1 escape the collision diagnostic offers) but "rename", because a - * curated platform name is never available to a package on any terms. - */ -export function capabilityPlatformNameRefusedDiagnostic(input: { - name: string; - declaredBy?: string | null; - grantedBy?: readonly string[]; -}): CapabilityPlatformNameRefusedDiagnostic { - const declaredBy = input.declaredBy ?? null; - const declarer = declaredBy ?? '(a declaration with no owning package)'; - const grantedBy = [...(input.grantedBy ?? [])]; - const blastRadius = grantedBy.length > 0 - ? `Permission set(s) granting it: ${grantedBy.join(', ')} — those grants keep resolving, ` + - `against the PLATFORM's capability rather than this package's declaration.` - : `No bootstrap permission set grants it, so nothing is denied today — but the declaration ` + - `is still inert.`; - return { - event: CAPABILITY_PLATFORM_NAME_REFUSED, - severity: 'warning', - name: input.name, - declaredBy, - grantedBy, - message: - `[security] [${CAPABILITY_PLATFORM_NAME_REFUSED}] Package ${declarer} declares capability ` + - `"${input.name}", but that name is a CURATED PLATFORM capability. The declaration was NOT ` + - `applied — a package must never redefine a platform-owned capability — so the curated pass ` + - `keeps the sys_capability row, the declaring package's authored label, description and ` + - `scope are not in effect, and the capability carries no package provenance (ADR-0086 D3). ` + - `The name still resolves against the curated row, which is why nothing else reports this. ` + - `${blastRadius}`, - fix: - `Rename the capability in package ${declarer} to a name it owns (prefix it with the ` + - `package's own module prefix). ⛔ A curated platform name is not co-ownable: unlike a ` + - `package-to-package collision (ADR-0130 D1) there is no arrangement under which a package ` + - `may declare it.`, - }; -} - -/** Report one curated-name refusal so it reaches the author with no sink injected. */ -export function reportCapabilityPlatformNameRefused( - logger: CollisionReportSink | undefined, - d: CapabilityPlatformNameRefusedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - declaredBy: d.declaredBy, - grantedBy: [...d.grantedBy], - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 2 — a declared CAPABILITY with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared capability refused for want of an owning package. */ -export interface CapabilityDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof CAPABILITY_DECLARATION_UNOWNED; - readonly name: string; - /** The bootstrap permission set(s) granting it. */ - readonly grantedBy: readonly string[]; - /** Whether a `sys_capability` row already resolves the name — it decides the consequence. */ - readonly hasRow: boolean; -} - -/** - * Word the unowned-capability refusal. - * - * ⚠️ [#4967 Part 3] The three-way consequence below is LOAD-BEARING and is - * preserved verbatim from the sentence this card found at the call site: a - * seeder-side warn that names the capability but not the permission set(s) that - * grant it does not tell the reader what happened. `hasRow` is the reason the - * existence read is taken BEFORE the refusal rather than after it. - */ -export function capabilityDeclarationUnownedDiagnostic(input: { - name: string; - grantedBy?: readonly string[]; - hasRow: boolean; -}): CapabilityDeclarationUnownedDiagnostic { - const grantedBy = [...(input.grantedBy ?? [])]; - const granted = grantedBy.length > 0 - ? `granted by ${grantedBy.join(', ')}` - : 'granted by no bootstrap permission set'; - const consequence = input.hasRow - ? 'an existing sys_capability row already resolves it and is left as-is — the declaration adds no package provenance' - : grantedBy.length > 0 - ? 'falls back to the back-compat derived placeholder — the grant resolves, but with no package provenance (ADR-0086 D3: uninstall undefined)' - : 'nothing derives it either — the capability is materialized nowhere'; - return { - event: CAPABILITY_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - grantedBy, - hasRow: input.hasRow, - message: - `[security] [${CAPABILITY_DECLARATION_UNOWNED}] declared capability "${input.name}" has no ` + - `owning package (${granted}): ${consequence}`, - fix: - `Stamp the declaring package on the capability — the SchemaRegistry's _packageId ` + - `(ADR-0010), or the spec-level packageId (ADR-0086 D3) as the author-declared fallback. ` + - `A package-managed row with no package_id is the ambiguity ADR-0086 D3 removes, which is ` + - `why this seeder writes none.`, - }; -} - -/** Report one unowned-capability refusal so it reaches the author with no sink injected. */ -export function reportCapabilityDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: CapabilityDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message}. Fix: ${d.fix}`, { - event: d.event, - name: d.name, - grantedBy: [...d.grantedBy], - hasRow: d.hasRow, - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 3 — declared capabilities whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the capability pass. - * - * ONE line per pass with the count, never a line per name: a database that is - * down refuses every name at once and a warn each is a flood that buries its - * own meaning. - * - * ⚠️ The consequence has to be SAID. "Unreadable" alone states none, and the - * two halves differ: a genuinely new declaration among these names has no row - * at all, while one whose stored metadata drifted from the shipped declaration - * keeps the stale value. Nothing was written, so nothing is lost — the next - * boot with a readable database does both — but until then the registry does - * not reflect what these packages declare. - * - * ⛔ This one is deliberately NOT exported from the package entry: unlike the - * two refusals an author can cause, an unreadable database is a runtime - * condition no compile-time door can raise, so there is no second consumer by - * construction (the discrimination `position_name_fold_grant` already makes). - */ -export function reportCapabilityRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number }, -): void { - reportThroughSink( - logger, - `[security] [${CAPABILITY_ROWS_UNREADABLE}] declared capabilities left untouched — their ` + - `sys_capability rows could not be read. ${input.unreadable} of ${input.total} declaration(s) ` + - `were neither created nor reconciled: a genuinely new one among them has no sys_capability ` + - `row, and one whose stored label, description or scope has drifted from the shipped ` + - `declaration keeps the stale value. Nothing was written and nothing is lost — the next boot ` + - `with a readable database does both — but until then the registry does not reflect what ` + - `these packages declare.`, - { event: CAPABILITY_ROWS_UNREADABLE, unreadable: input.unreadable, total: input.total }, - ); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 4 — a declared PERMISSION SET with no resolvable owning package. -// ─────────────────────────────────────────────────────────────────────────── - -/** One declared permission set refused for want of an owning package. */ -export interface PermissionSetDeclarationUnownedDiagnostic extends SeedRefusalDiagnosticBase { - readonly event: typeof PERMISSION_SET_DECLARATION_UNOWNED; - readonly name: string; - /** The organization whose pass refused it, when the walled per-organization door is in force. */ - readonly organizationId?: string; -} - -/** - * Word the unowned-permission-set refusal. - * - * ⚠️ The consequence is NOT the capability axis'. `stack.permissions` has - * always been runtime-ENFORCED — the evaluator resolves declared sets through - * the metadata registry — so every grant in the refused set keeps working. What - * is lost is the RECORD (ADR-0086 D5, the ADR-0078 inert-metadata smell this - * seeder exists to close): the Setup admin surface reads `sys_permission_set` - * and cannot see the set, there is no provenance axis for it, and uninstall - * (`cleanupPackagePermissions`) has nothing to reap. An author told "not - * materialized" and left to guess will go looking for a denied user who does - * not exist. - */ -export function permissionSetDeclarationUnownedDiagnostic(input: { - name: string; - organizationId?: string; -}): PermissionSetDeclarationUnownedDiagnostic { - return { - event: PERMISSION_SET_DECLARATION_UNOWNED, - severity: 'warning', - name: input.name, - ...(input.organizationId ? { organizationId: input.organizationId } : {}), - message: - `[security] [${PERMISSION_SET_DECLARATION_UNOWNED}] declared permission set ` + - `"${input.name}" has no owning package — not materialized. A managed_by:'package' row ` + - `without a package_id is the ambiguity ADR-0086 D3 removes, so no row is written. The ` + - `evaluator still resolves the DECLARED set through the metadata registry, so its object, ` + - `field, tab and system permissions all keep working — what is missing is the RECORD: the ` + - `Setup admin surface reads sys_permission_set and cannot see this set, it carries no ` + - `provenance axis, and uninstall has nothing to reap (ADR-0086 D5).`, - fix: - `Stamp the declaring package on the set — the SchemaRegistry's _packageId (ADR-0010), or ` + - `the spec-level packageId (ADR-0086 D3) as the author-declared fallback.`, - }; -} - -/** Report one unowned-set refusal so it reaches the author with no sink injected. */ -export function reportPermissionSetDeclarationUnowned( - logger: CollisionReportSink | undefined, - d: PermissionSetDeclarationUnownedDiagnostic, -): void { - reportThroughSink(logger, `${d.message} Fix: ${d.fix}`, { - event: d.event, - name: d.name, - ...(d.organizationId ? { organization: d.organizationId } : {}), - fix: d.fix, - }); -} - -// ─────────────────────────────────────────────────────────────────────────── -// SITE 5 — declared permission sets whose rows could not be READ. -// ─────────────────────────────────────────────────────────────────────────── - -/** - * Report the unreadable-rows summary for the permission-set pass. - * - * ⚠️ Said once with the count, and the consequence spelled out for the SAME - * reason as the capability summary and with a DIFFERENT content: silence here - * reads exactly like "everything was already in order". The declared sets stay - * runtime-enforced through the metadata registry, so nothing is denied — what - * did not happen is the materialization, and a drifted row keeps its stale - * grants until a boot that can read the table. - * - * ⛔ Package-private for the same reason as its capability sibling: an - * unreadable database is a runtime condition, not an authoring fault. - */ -export function reportPermissionSetRowsUnreadable( - logger: CollisionReportSink | undefined, - input: { unreadable: number; total: number; organizationId?: string }, -): void { - reportThroughSink( - logger, - `[security] [${PERMISSION_SET_ROWS_UNREADABLE}] declared permission sets left untouched — ` + - `their records could not be read. ${input.unreadable} of ${input.total} declared set(s) ` + - `were neither seeded nor reconciled: a genuinely new set has no sys_permission_set row, so ` + - `the Setup admin surface cannot see it and uninstall has nothing to reap (ADR-0086 D5), and ` + - `a set whose stored grants drifted from the shipped declaration keeps the stale row. The ` + - `evaluator still resolves the DECLARED sets through the metadata registry, so no grant is ` + - `denied today; the next boot with a readable database materializes them.`, - { - event: PERMISSION_SET_ROWS_UNREADABLE, - unreadable: input.unreadable, - total: input.total, - ...(input.organizationId ? { organization: input.organizationId } : {}), - }, - ); -} diff --git a/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts b/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts deleted file mode 100644 index 45af1d0dfcf..00000000000 --- a/packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#11532] A FRESH walled deployment mints the PLATFORM BUCKET - * organization-less — and the per-organization pass must stop calling the - * platform's own output a "pre-fix leftover" with a remedy that recreates it. - * - * ## What was measured on a real deployment - * - * A fresh boot of the walled HotCRM SaaS rig (`OS_TENANCY_POSTURE=isolated`, - * three organizations, one SQLite file) stored 50 `sys_permission_set` rows: - * 14 x 3 organizations, plus **8 organization-less** ones carrying - * `managed_by = 'platform'` / `package_id = NULL`, written 1.3 s BEFORE the - * deployment's first `sys_organization` row existed. `bootstrapPlatformAdmin` - * wrote them — it is the fifth seeder, outside the four #11121 converted, and - * its own log line names the count (`{"seeded":8,...}`). - * - * ## What is NOT in scope here, and why the minting is left alone - * - * ⛔ Routing that seeding through the per-organization pass is **ruled out of - * this landing**. #10103's maintainer ruling of 2026-08-20 (Q1/Q2, live - * decision-inbox session) closes it verbatim: - * - * > Q1's platform-defaults residue (`bootstrapPlatformAdmin`'s three sets, - * > the env-door projection) stays outside this card, unreaped and loudly - * > warned about under walled posture; whether customers ever need those - * > visible is a future small decision card, filed only when an onboarding - * > flow actually wants it. - * - * The rows are also load-bearing: PLATFORM_ADMIN is DERIVED from an unscoped - * `sys_user_permission_set` grant pointing at the organization-less - * `admin_full_access` row BY ROW ID (`resolve-authz-context.ts` section 6b), - * which is the same reason the ruled Option C refused a #8617-breadth reap. - * - * So section 1 pins the minting as CURRENT, RULED behaviour — by row identity, - * not by count, because two offsetting errors hold a count constant while the - * identity inverts. What this card repairs is section 3: the diagnostic. - * - * ## Why the diagnostic could not be measured with the diagnostic - * - * The pass's own verdict is self-falsifying — it reports the platform's output - * of 1.3 s earlier as legacy state the operator never had, and offers - * "re-initialize the deployment", which mints exactly these rows again on the - * next boot. Its output therefore cannot serve as evidence for anything, - * including that this fix worked. Every assertion below reads STORED ROWS off - * knex (past every engine-side projection) or the logger's recorded calls — - * never the pass's summary. - * - * ## Why a real engine and a real driver - * - * The defect lives where a scope the ENGINE threads meets a predicate the - * DRIVER emits, exactly as in `per-organization-catalog.test.ts`. A hand-built - * engine double implements neither, so these cases run a real `SqlDriver` on - * better-sqlite3 `:memory:` behind a real `ObjectQL`, drive the SHIPPED - * seeders, and seed from the SHIPPED `defaultPermissionSets` — the same array - * `security-plugin.ts` hands to `bootstrapPlatformAdmin` and puts on the - * manifest as `permissions`. - */ - -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; - -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; - -const ORGS = ['org_jia', 'org_yi', 'org_bing'] as const; -const SECURITY_PACKAGE = 'com.objectstack.plugin-security'; - -/** The platform bucket's names, read off the SHIPPED declaration. */ -const PLATFORM_BUCKET_NAMES = defaultPermissionSets.map((ps) => ps.name).sort(); - -/** - * One extra declared set that the platform bootstrap does NOT seed. It is the - * positive control for the OTHER class: a genuine pre-fix organization-less - * row, for which "re-initialize the deployment" remains the correct remedy and - * must keep being offered. - */ -const ACME_SET = { name: 'acme_readonly', label: 'Acme RO', _packageId: 'com.acme.crm', objects: {} }; - -/** - * What `manifest.register({ permissions: this.bootstrapPermissionSets })` - * produces: the platform bucket's own declarations, provenance-stamped for - * plugin-security, plus one foreign package's set. - */ -const DECLARED_PERMISSIONS = [ - ...defaultPermissionSets.map((ps) => ({ ...(ps as any), _packageId: SECURITY_PACKAGE })), - ACME_SET, -]; - -function recordingLogger() { - const warns: Array<{ message: string; meta: any }> = []; - const infos: Array<{ message: string; meta: any }> = []; - return { - warns, - infos, - logger: { - info: (message: string, meta?: any) => { infos.push({ message, meta }); }, - warn: (message: string, meta?: any) => { warns.push({ message, meta }); }, - error: (_m: string, _meta?: any) => { /* the walled "ZERO config-derived platform administrators" backstop — over CONFIGURATION, not elevation; not this card's subject */ }, - }, - }; -} - -const engines: ObjectQL[] = []; -let posture: string | undefined; - -beforeEach(() => { - posture = process.env.OS_TENANCY_POSTURE; - process.env.OS_TENANCY_POSTURE = 'isolated'; -}); - -afterEach(async () => { - if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = posture; - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.walled-platform-bucket-11532', - name: 'Walled platform bucket', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysOrganization, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The delegating seam `per-organization-catalog.test.ts` uses: every verb - * reaches the real ObjectQL, `update` opens with the PRODUCER's own dispatch - * predicate, and `registry` answers `readDeclared`. - */ -function withRegistry(engine: any, declared: any[] = DECLARED_PERMISSIONS): any { - return { - find: (o: string, q?: any, opt?: any) => engine.find(o, q, opt), - insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), - update: (o: string, d: any, opt?: any) => { - assertEngineUpdateDispatch(d, opt); - return engine.update(o, d, opt); - }, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - }; -} - -/** Ground truth: every stored row, straight off knex, past all tenancy. */ -async function stored(engine: ObjectQL, table = 'sys_permission_set'): Promise { - const driver: any = (engine as any).getDriver(table); - return driver.knex(table).select('*'); -} - -const orgOf = (r: any): string | null => (r.organization_id ?? null); -const orgLess = (rows: any[]) => rows.filter((r) => orgOf(r) === null); - -/** The boot order `security-plugin.ts` runs: platform bootstrap, then one pass per organization. */ -async function walledBoot(engine: ObjectQL, logger: any): Promise<{ seeded: number }> { - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - for (const org of ORGS) { - await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - } - return { seeded: report.seeded }; -} - -/** The one warning class this card is about, whatever wording it ends up carrying. */ -const bucketWarns = (warns: Array<{ message: string; meta: any }>) => - warns.filter((w) => (w.meta?.names ?? []).some((n: string) => PLATFORM_BUCKET_NAMES.includes(n))); - -describe('[#11532] the walled platform bucket and the diagnostic that describes it', () => { - it('1. PREMISE (ruled, unchanged): a fresh walled boot mints the platform bucket organization-less — pinned by ROW IDENTITY, not count', async () => { - const engine = await boot(); - const { logger, infos } = recordingLogger(); - - const { seeded } = await walledBoot(engine, logger); - const rows = await stored(engine); - const bucket = orgLess(rows); - - // (a) IDENTITY, name by name — a count would hold constant under two - // offsetting errors while the membership inverted. - expect(bucket.map((r) => r.name).sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(seeded).toBe(PLATFORM_BUCKET_NAMES.length); - // The eight names the rig measured are named literally, so a silent change - // to the shipped declaration cannot quietly redefine what this pins. - expect(PLATFORM_BUCKET_NAMES).toEqual([ - 'admin_full_access', - 'mcp_agent_data_read', - 'mcp_agent_data_write', - 'mcp_agent_restricted', - 'member_default', - 'organization_admin', - 'organization_admin_no_bypass', - 'viewer_readonly', - ]); - - // (b) The provenance the rig measured: platform-owned, no package. - for (const row of bucket) { - expect(row.managed_by).toBe('platform'); - expect(row.package_id ?? null).toBeNull(); - } - - // (c) Every organization still holds its OWN complete copy, package-owned — - // which is why nothing is broken for a reader and why the bucket is not - // reaped. Identity again: the per-organization row is a DIFFERENT row id. - const bucketIds = new Set(bucket.map((r) => r.id)); - for (const org of ORGS) { - const own = rows.filter((r) => orgOf(r) === org); - expect(own.map((r) => r.name).sort()).toEqual([...PLATFORM_BUCKET_NAMES, ACME_SET.name].sort()); - for (const r of own) { - expect(bucketIds.has(r.id)).toBe(false); - expect(r.managed_by).toBe('package'); - } - expect(own.filter((r) => r.name === 'admin_full_access')[0].package_id).toBe(SECURITY_PACKAGE); - } - - // (d) The PRODUCER says what it wrote. The rig's boot log read - // `{"seeded":8}` with nothing to indicate the rows carried no organization, - // so the operator's first sight of them was a warning calling them legacy - // state. Under a walled posture the seeder now names the bucket itself. - const declared = infos.filter((i) => i.message.includes('the platform bucket')); - expect(declared).toHaveLength(1); - expect([...(declared[0].meta?.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(declared[0].meta?.seeded).toBe(PLATFORM_BUCKET_NAMES.length); - }); - - it('2. the per-organization sweep leaves the platform bucket ROWS untouched — same ids, same values', async () => { - const engine = await boot(); - const { logger } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - const before = JSON.stringify(orgLess(await stored(engine)).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - for (const org of ORGS) await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - const after = JSON.stringify(orgLess(await stored(engine)).map((r) => ({ ...r })).sort((a, b) => `${a.id}`.localeCompare(`${b.id}`))); - - // Byte-equal: not reaped, not adopted, not re-stamped. The bucket is exactly - // what the ruling said it would be, and its ids are still what the unscoped - // PLATFORM_ADMIN grant points at. - expect(after).toBe(before); - }); - - it('3. THE FIX: the platform bucket is NOT reported as a pre-fix leftover, and its remedy is not the loop', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - await walledBoot(engine, logger); - - const guard = bucketWarns(warns); - // Once per organization — the guard is per pass, and silence would be the - // OTHER failure (the ruling requires these rows be warned about loudly). - expect(guard).toHaveLength(ORGS.length); - - for (const org of ORGS) { - const forOrg = guard.filter((w) => w.meta?.organization === org); - expect(forOrg).toHaveLength(1); - const [w] = forOrg; - // (a) Every platform-bucket name is named — loudly, by name, as ruled. - expect([...(w.meta.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - expect(w.meta.count).toBe(PLATFORM_BUCKET_NAMES.length); - - // (b) NOT pre-fix. On a fresh deployment there are no pre-fix rows; these - // were minted by this same boot, seconds earlier. Asserted on the - // MACHINE-READABLE classification rather than on prose, so a reworded - // message cannot quietly re-merge the two classes. - expect(w.meta.origin).toBe('platform-bucket'); - expect(w.message).toContain('PLATFORM BUCKET'); - expect(w.message).toContain('on every boot'); - expect(w.message).not.toContain('pre-fix organization-less'); - - // (c) The remedy is not the loop. "Remedy: re-initialize the deployment" - // recreates exactly these rows on the next boot, so it is not offered. - expect(w.message).not.toContain('Remedy: re-initialize the deployment'); - expect(w.message).toContain('Re-initializing the deployment does NOT clear'); - - // (d) …and the reader is told the thing that actually matters: their own - // catalog is complete, so no action is required. - expect(w.message).toContain("own copies WERE created"); - expect(w.message).toContain('no action is required'); - } - }); - - it('4. POSITIVE CONTROL: a genuine pre-fix organization-less row still gets the original wording AND the re-initialize remedy', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - // A pre-fix deployment: ONE organization-less pass through the SHIPPED - // seeder, the pre-#11121 behaviour, carrying only the foreign package's - // set. `acme_readonly` is not in the platform bucket, so its leftover is - // the real thing — a re-initialized deployment really would not have it. - await bootstrapDeclaredPermissions(withRegistry(engine, [ACME_SET]), null, { logger }); - expect(orgLess(await stored(engine)).map((r) => r.name)).toEqual([ACME_SET.name]); - - warns.length = 0; - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - for (const org of ORGS) await bootstrapDeclaredPermissions(ql, null, { logger, organizationId: org }); - - const preFix = warns.filter((w) => (w.meta?.names ?? []).includes(ACME_SET.name)); - expect(preFix).toHaveLength(ORGS.length); - for (const w of preFix) { - expect(w.meta.origin).toBe('pre-fix-residue'); - expect(w.message).toContain('pre-fix organization-less'); - expect(w.message).toContain('re-initialize the deployment'); - expect(w.message).toContain('adopt'); - expect(w.message).toContain('NOT deleted'); - // The two classes are reported SEPARATELY: a pre-fix warning that also - // carried the platform bucket's names would hand the operator a remedy - // that is a loop for most of the list it prints. - expect([...(w.meta.names ?? [])]).toEqual([ACME_SET.name]); - } - }); - - it('5. `single` posture is untouched: organization-less is the correct shape there, and nothing is warned', async () => { - const engine = await boot(); - const { logger, warns, infos } = recordingLogger(); - const ql = withRegistry(engine); - process.env.OS_TENANCY_POSTURE = 'single'; - - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - await bootstrapDeclaredPermissions(ql, null, { logger }); - - const rows = await stored(engine); - expect(rows.every((r) => orgOf(r) === null)).toBe(true); - expect(warns.filter((w) => (w.meta?.names ?? []).length > 0)).toEqual([]); - // …and the producer's walled-only declaration stays silent here: an - // organization-less row is the CORRECT shape under `single`. - expect(infos.filter((i) => i.message.includes('the platform bucket'))).toEqual([]); - }); - - it('6. a host that OVERRODE the default sets is classified against ITS array, not the shipped one', async () => { - const engine = await boot(); - const { logger, warns } = recordingLogger(); - const ql = withRegistry(engine); - for (const org of ORGS) { - await (engine as any).insert('sys_organization', { id: org, name: org }, { context: { isSystem: true } }); - } - - // The counterfactual `SecurityPluginOptions.defaultPermissionSets` creates: - // the organization-less writer seeds a DIFFERENT list, so `acme_readonly` - // becomes the re-minted name and the shipped eight become genuine leftovers. - await bootstrapDeclaredPermissions(withRegistry(engine, [ACME_SET]), null, { logger }); - await bootstrapPlatformAdmin(ql, defaultPermissionSets as any[], { logger }); - - warns.length = 0; - for (const org of ORGS) { - await bootstrapDeclaredPermissions(ql, null, { - logger, - organizationId: org, - platformBucketNames: [ACME_SET.name], - }); - } - - const bucket = warns.filter((w) => w.meta?.origin === 'platform-bucket'); - const preFix = warns.filter((w) => w.meta?.origin === 'pre-fix-residue'); - expect(bucket).toHaveLength(ORGS.length); - expect(preFix).toHaveLength(ORGS.length); - for (const w of bucket) expect([...(w.meta.names ?? [])]).toEqual([ACME_SET.name]); - // Positive control on the same run: the shipped eight are NOT silently - // swallowed — they move to the other class rather than disappearing. - for (const w of preFix) expect([...(w.meta.names ?? [])].sort()).toEqual(PLATFORM_BUCKET_NAMES); - }); -}); From 77ad5f2963cd2ddd09bc936b8306700fe6cc66e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:07:59 +0000 Subject: [PATCH 02/39] wip(plugin-security,cli): the platform-admin bootstrap writes no catalog row; os meta resync is retired (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- content/docs/data-modeling/drivers.mdx | 2 +- .../meta/resync-skip-explanation.test.ts | 80 ---- packages/cli/src/commands/meta/resync.ts | 243 ------------ packages/cli/src/utils/format.ts | 2 +- ...form-migrations-arming.integration.test.ts | 2 +- ...te.dev-self-heal-fence.integration.test.ts | 4 +- .../schema-migrate.lazy-driver-import.test.ts | 2 +- ...igrate.one-shot-family.integration.test.ts | 8 +- packages/cli/src/utils/schema-migrate.ts | 12 +- .../cli/test/json-stdout-purity.e2e.test.ts | 1 - packages/drivers/driver-sql/src/sql-driver.ts | 2 +- ...tform-admin-refused-existence-read.test.ts | 67 ++-- ...p-platform-admin-seeded-provenance.test.ts | 369 ------------------ .../src/bootstrap-platform-admin.test.ts | 143 ------- .../src/bootstrap-platform-admin.ts | 318 +++------------ .../src/claim-seed-ownership.ts | 2 +- .../src/permission-set-projection.ts | 2 +- packages/runtime/src/standalone-stack.ts | 2 +- .../references/operations.md | 2 +- 19 files changed, 94 insertions(+), 1169 deletions(-) delete mode 100644 packages/cli/src/commands/meta/resync-skip-explanation.test.ts delete mode 100644 packages/cli/src/commands/meta/resync.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5065db485e9..3021dfe3ccd 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -735,7 +735,7 @@ A file-backed SQLite database is switched to **WAL** (write-ahead logging) the first time ObjectStack connects to it. SQLite's own default is a rollback journal (`journal_mode = delete`), which is the wrong trade for the way this platform is actually used — **several processes on one file**: a dev server, -`os migrate`, `os meta resync`, a test run. +`os migrate`, a test run. | | rollback journal (SQLite default) | WAL (ObjectStack default) | |:---|:---|:---| diff --git a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts b/packages/cli/src/commands/meta/resync-skip-explanation.test.ts deleted file mode 100644 index 6408df66492..00000000000 --- a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #9184 — `os meta resync` reports a nonzero skip count with no explanation. -// -// The docblock half (#9130 / PR #9183) already explains, IN SOURCE, that a -// stored `'admin'` (or legacy `'user'`) stamp is not always a deliberate -// Setup takeover — on any install created before commit 712e185db the platform's own -// seeder wrote that exact stamp, so `resynced 0 / skipped N` is a permanent, -// by-design outcome there. This card is the runtime-output half: the same -// explanation has to reach the operator who is staring at the terminal, -// never having gone looking for the source. -// -// What is pinned here: -// • the explanation fires exactly when `resyncSkipped > 0` — the same -// condition the skip-count summary itself uses, never a narrower one -// (e.g. `resynced === 0`); -// • it names BOTH spellings of the legacy-vs-current provenance value -// (`admin` and `user`), because a stored legacy row can surface under -// either; -// • it says the skip is expected / by design / not a failure — the whole -// point of the card; -// • it does NOT reintroduce "intentional override" framing for the -// admin-owned case — the docblock removed that as false (before commit 712e185db the -// seeder wrote the stamp, not an admin), and this line must not -// contradict it. - -import { describe, it, expect } from 'vitest'; -import { resyncSkipExplanationLine } from './resync.js'; - -describe('resyncSkipExplanationLine — the runtime-output half of #9184', () => { - it('is silent when nothing was skipped', () => { - expect(resyncSkipExplanationLine(0)).toBeNull(); - }); - - it('is silent for a negative count too (defensive — should never occur)', () => { - expect(resyncSkipExplanationLine(-1)).toBeNull(); - }); - - it('fires for a full skip (the classic pre-#8692 "resynced 0 / skipped N" case)', () => { - const line = resyncSkipExplanationLine(8); - expect(line).not.toBeNull(); - expect(line).toContain('admin'); - expect(line).toContain('user'); - expect(line).toContain("before the seeder began stamping its default sets 'platform'"); - }); - - it('fires identically for a PARTIAL skip — same trigger as the summary, not `resynced === 0`', () => { - // The card requires this line to ride the same condition as the skip - // summary (`resyncSkipped > 0`), not a narrower "totally stuck" gate. A - // caller with resynced=3/skipped=2 must get the same explanation as - // resynced=0/skipped=8. - expect(resyncSkipExplanationLine(2)).toBe(resyncSkipExplanationLine(8)); - }); - - it('says the outcome is expected / by design / not a failure', () => { - const line = resyncSkipExplanationLine(1)!; - expect(line.toLowerCase()).toMatch(/expected|by design/); - expect(line.toLowerCase()).toContain('not a failure'); - }); - - it('never claims the admin-owned case is a deliberate override — the framing PR #9183 removed', () => { - const line = resyncSkipExplanationLine(1)!; - // The case predating commit 712e185db is exactly the platform's own seeder inheriting a - // field default, not an administrator deciding anything — the docblock - // in bootstrap-platform-admin.ts is explicit that stating this as - // "(intentional override)" is a lie for those rows. This line must keep - // that same neutral framing rather than reintroducing it. - expect(line).toContain("isn't always a deliberate Setup takeover"); - expect(line).not.toMatch(/admin[^.]*\(intentional override\)/i); - }); - - it('keeps the package-owned case correctly framed as ALWAYS deliberate, unlike admin', () => { - // Unlike a stored 'admin'/'user' stamp, a package-owned row genuinely is - // always a deliberate override — the docblock draws this distinction and - // the runtime line must not blur it by applying uniform "override" - // language to both provenance classes. - const line = resyncSkipExplanationLine(1)!; - expect(line).toMatch(/package-owned row.*always a deliberate override/s); - }); -}); diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts deleted file mode 100644 index b1c92228da8..00000000000 --- a/packages/cli/src/commands/meta/resync.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { Command, Flags } from '@oclif/core'; -import chalk from 'chalk'; -import { createInterface } from 'node:readline'; -import { - printHeader, - printSuccess, - printWarning, - printError, - printInfo, - printStep, - createTimer, - emitJson, - errorCodeFields, -} from '../../utils/format.js'; -import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; - -async function confirm(question: string): Promise { - if (!process.stdin.isTTY) return false; // non-interactive → require --yes - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer: string = await new Promise((resolve) => rl.question(question, resolve)); - return /^y(es)?$/i.test(answer.trim()); - } finally { - rl.close(); - } -} - -function safeGetService(kernel: any, name: string): any { - try { - return kernel?.getService?.(name); - } catch { - return undefined; - } -} - -/** - * The dim explanatory line printed under the skip-summary `printWarning` - * when `resyncSkipped > 0` — the runtime-output half of #9184 (the docblock - * half, #9130/PR #9183, already explains this in source). - * - * `null` when nothing was skipped, so the caller's `if (resyncSkipped > 0)` - * gate is the single source of truth for the trigger condition: this line - * rides the SAME condition as the skip-count summary it sits under, never a - * separate one (e.g. `resynced === 0`) — a partial skip deserves the same - * explanation as a total one, and "alongside the skip summary" in the issue - * means exactly that pairing. - * - * Deliberately neutral about the skipped row's OWN provenance, matching the - * seeder docblock (`bootstrap-platform-admin.ts`) this line must not - * contradict: the skip decision is unconditionally intentional (only - * platform-owned rows are ever reconciled), but a stored `'admin'` stamp is - * NOT always a deliberate Setup takeover — on any install predating commit 712e185db - * the platform's own seeder wrote that exact stamp, so `resynced 0 / skipped - * N` is a permanent, by-design outcome there rather than a sign the command - * failed. `'user'` is named too: it is the legacy spelling of the same - * class, healed to `'admin'` by `normalizeManagedByVocab`, so a stored - * legacy row can still surface here under either spelling. - */ -export function resyncSkipExplanationLine(resyncSkipped: number): string | null { - if (resyncSkipped <= 0) return null; - // One unwrapped sentence per concatenated piece — never split by an - // embedded '\n' — so a key phrase can never straddle a hard line break - // (the terminal soft-wraps on its own, same as the file's other dim - // follow-up lines a few lines up). - return ( - " Expected, not a failure — resync only reconciles platform-owned rows. " + - "A stored 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup takeover: " + - "on installs created before the seeder began stamping its default sets 'platform', the platform's own " + - "seeded defaults carry that same stamp, so a " + - "persistent skip count here can be permanent by design. A package-owned row, by contrast, is " + - "always a deliberate override by the package that owns it." - ); -} - -/** - * `os meta resync` — reconcile materialized metadata to the compiled `dist` - * without a `--fresh` wipe (#2705). - * - * The default permission sets (admin_full_access / member_default / - * viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never - * clobbered, so an edit to a default set's source is served with its OLD value - * until the DB is wiped — a silent dev-loop trap (a permission-gated action - * "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared - * permission sets, positions, built-in roles, capabilities) already upserts on - * boot; this command closes the one remaining gap by force-reconciling the - * default permission-set rows to the shipped declaration. - * - * Business data is never touched — only `sys_permission_set` definition rows, - * and only rows still `managed_by:'platform'` are reconciled. A row a package - * owns (`'package'`) is left alone as a deliberate override. A row stamped - * `'admin'` — or the legacy spelling `'user'`, healed to `'admin'` by the - * boot-time vocabulary normalizer (`normalizeManagedByVocab`) — is left alone - * too, but is NOT always a deliberate override: on any install created before - * commit 712e185db (the 2026-08-15 ruling) the platform's OWN seeded default sets carry that same - * `'admin'` stamp, indistinguishable from a genuine Setup takeover, so - * `resynced 0 / skipped N` is a permanent, by-design outcome on those - * installs rather than a bug. - */ -export default class MetaResync extends Command { - static override description = - 'Reconcile materialized metadata (default permission sets, which boot seeds insert-once) to the compiled dist without a --fresh wipe'; - - static override examples = [ - '$ os meta resync', - '$ os meta resync --yes', - '$ os meta resync --json', - ]; - - static override flags = { - 'database-url': Flags.string({ - description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)', - env: 'OS_DATABASE_URL', - }), - yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }), - json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }), - }; - - async run(): Promise { - const { flags } = await this.parse(MetaResync); - const timer = createTimer(); - let exitCode = 0; - - if (!flags.json) { - printHeader('Meta · resync'); - printStep('Booting runtime stack…'); - } - - // [#21391] A run that can never reach the write — no `--yes`, and nobody - // at a terminal to confirm (`--json`, or stdin not a TTY) — answers - // `confirmation_required` and writes nothing, so it boots READ-ONLY, the - // boot `os migrate plan` takes. A run that may write keeps the plain boot: - // `sys_permission_set` must exist before the resync writes into it, and - // the interactive prompt comes after the boot. - const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true); - - let stack; - try { - stack = await bootSchemaStack({ - jsonOutput: flags.json, - databaseUrl: flags['database-url'], - ...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), - }); - } catch (error: any) { - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - process.exit(1); - } - - try { - const ql = safeGetService(stack.kernel, 'objectql'); - // Prefer the SecurityPlugin's resolved set when a full stack is booted (it - // picks up an app's `defaultPermissionSets` override); fall back to the - // platform defaults, which is what the standalone stack this command boots - // exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds. - const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets'); - const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets; - - if (!ql) { - if (flags.json) { - await emitJson({ error: 'objectql_unavailable' }, 0, { compact: true }); - return; - } - printError('ObjectQL service is not available — cannot resync.'); - return; - } - if (!Array.isArray(sets) || sets.length === 0) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }, 0, { compact: true }); - return; - } - printWarning('No default permission sets are available to resync.'); - return; - } - - // Confirmation gate — resync overwrites the default permission-set - // definitions from the compiled dist, so admin Setup edits to those sets - // are replaced. Business data is never touched. - if (!flags.yes) { - if (flags.json || !process.stdin.isTTY) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); - return; - } - printWarning('Confirmation required. Re-run with --yes to resync.'); - return; - } - printInfo(`Database: ${chalk.white(stack.dbLabel)}`); - printWarning('This overwrites the default permission-set definitions from the compiled dist.'); - console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.')); - console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.')); - const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `)); - if (!ok) { - printInfo('Aborted — no changes made.'); - return; - } - } - - const logger = flags.json - ? undefined - : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; - - const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger }); - const resynced = report.resynced ?? 0; - const resyncSkipped = report.resyncSkipped ?? 0; - // seeded = existing + newly inserted; existing (under resync) = resynced + - // resyncSkipped, so the remainder is what this run freshly seeded. - const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); - - if (flags.json) { - await emitJson({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }); - return; - } - - console.log(''); - if (resynced > 0 || inserted > 0) { - printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`); - } else { - printInfo('Nothing reconciled.'); - } - if (resyncSkipped > 0) { - printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned).`); - const note = resyncSkipExplanationLine(resyncSkipped); - if (note) console.log(chalk.dim(note)); - } - console.log(chalk.dim(` ${timer.display()}`)); - console.log(''); - } catch (error: any) { - exitCode = 1; - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - } finally { - await stack.shutdown(); - // A one-shot command must exit even when the booted app stack left - // keep-alive handles open (schedulers/watchers registered on kernel:ready - // that `runtime.stop()` cannot fully drain). Matches the process.exit - // posture the other one-shot CLI commands use. - process.exit(exitCode); - } - } -} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 5804f510bee..60b6aa464bb 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -45,7 +45,7 @@ export const CLI_ALIAS = 'os'; * * Every other `--json` site in this CLI reports its duration INSIDE the * payload (`{ ...report, duration: timer.elapsed() }` — `os lint`, - * `os migrate meta`, `os migrate summary-nulls`, `os meta resync`), which is + * `os migrate meta`, `os migrate summary-nulls`), which is * what those two meant to do as well. With this union a duration in the exit * slot is a compile error, so the mistake cannot be made silently again. * diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index 55fc8398bea..1681e61b4a9 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -322,7 +322,7 @@ describe('#9380 the kernel:ready platform migrations, on the boots that reach th // WRITE modes boot without `deferSchemaDdl` — `--apply` of `os migrate // meta --stored`, `summary-nulls`, `value-shapes`, `recorded-by`, // `files-to-references` and `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes`. (Every + // --run`, `os secret orphans --delete`. (Every // no-write mode boots deferred since #21391.) Each writes only what the // operator confirmed; if the arming were gated on deferral instead of on // being a one-shot boot, every one of them would also repair rows the diff --git a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts index a935878def5..9bd08f8a6fe 100644 --- a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts @@ -11,8 +11,8 @@ * one-shot command boot under `NODE_ENV=development` would otherwise inherit * the self-heal: * - * - a NON-deferred one-shot boot (every write mode: `os meta resync --yes`, - * the data commands' `--apply`) runs boot schema sync, so it would apply + * - a NON-deferred one-shot boot (every write mode: the data commands' + * `--apply`) runs boot schema sync, so it would apply * safe drift its operator never saw in a preview; * - `os migrate plan` defers its DDL (#3917), so its own boot never reaches * the reconcile — this file pins that it stays write-free on the card's diff --git a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts index 62794d8f06a..9484e911919 100644 --- a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts +++ b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts @@ -7,7 +7,7 @@ * oclif's `findCommand` walks the command table and `import()`s every command * module on **every** CLI invocation, so a broken import chain anywhere in that * table is charged to whatever command you actually ran. `schema-migrate.ts` is - * shared by nine commands (`meta:resync`, `migrate`, and seven `migrate:*`), and + * was shared by nine commands (then `meta:resync`, `migrate`, and seven `migrate:*`), and * its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'` * meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print * nine `MODULE_NOT_FOUND` blocks (eighteen — `dev` forks a child) naming nine diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index adfba40bdd5..6dd09036b82 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -54,7 +54,6 @@ import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; -import MetaResync from '../commands/meta/resync.js'; import MigrateAccountIssuer from '../commands/migrate/account-issuer.js'; import MigrateApply from '../commands/migrate/apply.js'; import MigrateAuditMetadataBodies from '../commands/migrate/audit-metadata-bodies.js'; @@ -146,11 +145,6 @@ const invoke = (command: { run: (argv: string[], opts: { root: string }) => Prom * modes it has. The first case below holds this table equal to the source. */ const CALLERS: Record = { - 'commands/meta/resync.ts': { - run: invoke(MetaResync), - noWrite: [{ label: 'meta resync (no --yes)', argv: ['--database-url', '@DB@', '--json'] }], - write: [{ label: 'meta resync --yes', argv: ['--yes', '--database-url', '@DB@', '--json'] }], - }, 'commands/migrate/account-issuer.ts': { run: invoke(MigrateAccountIssuer), noWrite: [{ label: 'migrate account-issuer', argv: ['--database-url', '@DB@', '--json'] }], @@ -309,7 +303,7 @@ interface RunResult { * process-global and `emitJson` sets it, so it is saved and restored. The * stdout spy is installed before the boot, because a `--json` boot reserves * stdout and keeps whatever `process.stdout.write` is at that moment. - * `os meta resync` ends in `process.exit()`, so that is trapped too. + * A command that ends in `process.exit()` is trapped too. */ async function runJson(command: Invoke, argv: string[]): Promise { const savedExit = process.exitCode; diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index c59ed50e908..a3b8cb6ef2b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -137,7 +137,7 @@ export interface SchemaStack { dbSource: DatabaseSource; managedTableCount: number; /** The booted kernel — `getService('objectql')` etc. for one-shot commands - * beyond schema migration (e.g. `os meta resync`, #2705). */ + * beyond schema migration. */ kernel: any; /** * Create-table / add-column work the boot sync was held back from running @@ -579,7 +579,7 @@ export async function bootSchemaStack( * a `--json` flag, and each of them re-introduced the same defect * independently: `os migrate plan` / `apply` / `resume` / `recorded-by` / * `summary-nulls` / `value-shapes` / `files-to-references`, `os migrate - * meta --stored` and `os meta resync` all emitted ~60 INFO lines around + * meta --stored` and the since-retired `os meta resync` all emitted ~60 INFO lines around * their payload. Booting the stack is what makes a command a member of * this family, so this is the one place a new member cannot avoid — and * with no default, a new member has to *decide* rather than inherit the @@ -641,7 +641,7 @@ export async function bootSchemaStack( * about. * * A WRITE mode that needs the tables to exist before it writes (`--apply` - * of the data commands, `os meta resync --yes`) leaves this off. + * of the data commands) leaves this off. */ deferSchemaDdl?: boolean; /** @@ -789,7 +789,7 @@ export async function bootSchemaStack( // • the write modes — `--apply` of `os migrate meta --stored` / // `value-shapes` / `recorded-by` / `summary-nulls` / // `files-to-references` / `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes` — boot + // --run`, `os secret orphans --delete` — boot // NOT deferred, so a `defer`-keyed policy would have left all of them // repairing rows. They write, but only the change the operator // confirmed; `os migrate apply` likewise. A repair riding along is a @@ -1063,8 +1063,8 @@ export async function bootSchemaStack( * package is `import type`, which erases. A *static* value import for it was * not a local cost, because this file is not loaded only when someone migrates: * oclif's `findCommand` `import()`s every command module on **every** CLI - * invocation, and nine commands reach this file (`meta:resync`, `migrate`, and - * seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not + * invocation, and nine commands reached this file (then `meta:resync`, `migrate`, + * and seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not * merely break those nine — running *any* command, `os dev` included, printed * one `MODULE_NOT_FOUND` block per command in front of the output you asked for * (and `os dev` forks a child, so you saw each one twice), while the nine diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 698034ba9bc..0bb95fb871e 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -81,7 +81,6 @@ const COMMANDS_DIR = resolve(HERE, '../src/commands'); * --stored` rather than `migrate meta`. */ const FAMILY: Record = { - 'meta resync': [], // Its bare form boots: it takes the `os migrate plan` shape (`deferSchemaDdl` // + `readOnlyProbe`), so a fixture with no database file is opened as an // empty in-memory one rather than being brought into existence. No table diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 01edeb164c7..64886ac39d7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7067,7 +7067,7 @@ export class SqlDriver implements IDataDriver { * Put a file-backed SQLite database in WAL mode (#3941). * * ObjectStack's normal shape is **several processes on one file**: a dev - * server, `os migrate`, `os meta resync`, a test run. SQLite's built-in + * server, `os migrate`, a test run. SQLite's built-in * default — `journal_mode = delete`, a rollback journal — is the worst mode * for that, in two measured ways: * diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts index d57f5e44193..6e35b982e44 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts @@ -1,22 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#22169] `bootstrapPlatformAdmin`'s existence read: a read that did not + * [#22169] `bootstrapPlatformAdmin`'s one catalog read: a read that did not * answer is not an absent row. * - * The seed loop reads each default permission set BY NAME and inserts when it - * finds none. Its `tryFind` answers `[]` for a refused read as well as for an - * empty table, and the loop inserted on both — so on a table without the - * per-organization unique index a refused read minted a second row of a set - * that already existed, and a table holding such duplicates can never build - * that index again. Measured on a real engine with that index dropped: two - * declared sets, two INSERTs, under a refused read. + * [ADR-0131 D3/D4] The bootstrap writes no `sys_permission_set` row. It still + * READS the legacy `admin_full_access` row an older release seeded, because + * that row's id is how the holder scan reaches a grant written before the + * name column existed (leg C, restrict-only). A refused read cannot say no + * such grant exists, so the promotion is withheld — loudly — rather than risk + * minting a second administrator. * - * The double below has no `update` or `delete` member — the seed pass without - * `resync` calls neither — so it is not an engine double those dispatch - * contracts govern. Its `find` answers from its rows, bounded by the caller's - * `limit`; the refused read is a spy laid over it, so the two inputs the pass - * must tell apart — an answer, and a throw — come from one table. + * The double has no `update` or `delete` member — the bootstrap calls neither — + * so it is not an engine double those dispatch contracts govern. Its `find` + * answers from its rows, bounded by the caller's `limit`; the refused read is a + * spy laid over it. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -26,7 +24,7 @@ afterEach(() => { vi.restoreAllMocks(); }); function makeQl(seedRows: any[] = []) { const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: any[] = []; + const inserted: Array<{ object: string; data: any }> = []; return { rows, inserted, @@ -40,9 +38,7 @@ function makeQl(seedRows: any[] = []) { return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; }, async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - inserted.push({ ...data }); - rows.push({ ...data }); + inserted.push({ object, data: { ...data } }); return { id: data.id }; }, }; @@ -64,43 +60,30 @@ function capture() { return { warns, logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; } -describe('#22169 — bootstrapPlatformAdmin declines the insert when its existence read was refused', () => { - it('a refused read inserts NOTHING, and says so once for the whole pass', async () => { +describe('#22169 — bootstrapPlatformAdmin withholds the promotion when its legacy read was refused', () => { + it('a refused read writes NOTHING, reports the read that failed, and says so once', async () => { const ql = makeQl([{ id: 'ps_existing', name: 'admin_full_access' }]); refuseSetReads(ql); const { warns, logger } = capture(); - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default'), set('viewer_readonly')], { logger }); + const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted, '⛔ no row minted on a read that did not answer').toEqual([]); - expect(r.seeded).toBe(0); - const lines = warns.filter((w) => w.msg.includes('could not be read')); - expect(lines, 'one line per pass, never one per set').toHaveLength(1); - expect(lines[0]!.meta).toMatchObject({ - unreadable: 3, total: 3, names: ['admin_full_access', 'member_default', 'viewer_readonly'], - }); - // The promotion half reports the read that failed, not a set that is "missing". + expect(ql.inserted, '⛔ nothing written on a read that did not answer').toEqual([]); expect(r.reason).toBe('admin_permission_set_unreadable'); + expect(r.adminPromoted).toBe(false); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toHaveLength(1); }); - it('control: an EMPTY answer is still "absent" — a fresh install seeds every default set', async () => { + it('control: an EMPTY answer is "no legacy row" — the pass goes on, and writes no catalog row', async () => { const ql = makeQl(); const { warns, logger } = capture(); const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - expect(ql.inserted.map((row) => row.name)).toEqual(['admin_full_access', 'member_default']); - expect(r.seeded).toBe(2); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); - - it('control: an existing row is found and left alone — no insert, no unreadable line', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'member_default' }]); - const { warns, logger } = capture(); - - await bootstrapPlatformAdmin(ql, [set('member_default')], { logger }); - - expect(ql.inserted).toEqual([]); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); + expect(ql.inserted.filter((w) => w.object === 'sys_permission_set')).toEqual([]); + // No users in this double, so the pass ends at "nobody to promote yet" — + // past the legacy read, which is the point. + expect(r.reason).toBe('no_users'); + expect(warns.filter((w) => w.msg.includes('promotion WITHHELD'))).toEqual([]); }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts deleted file mode 100644 index 92957e006b3..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the - * platform default permission sets, and what `os meta resync` then does with it. - * - * ## Why this file exists at all - * - * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — - * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every - * one of them hand-builds the pre-image. That is correct unit coverage of the - * BRANCH, and it is deliberately left untouched here. What none of them can - * answer is which pre-image the seeder itself produces, because a fake `ql` - * stores exactly the columns it is handed and a declared field `defaultValue` - * never runs. So the one row shape the platform actually creates was the one - * row shape nothing exercised, and the drift was invisible in both directions. - * - * This file closes that by seeding through the REAL shipped - * `defaultPermissionSets` against a real `ObjectQL` engine over a real - * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and - * `os meta resync` use, which hand the bare engine straight to the seeder. - * - * ## The ruling these assertions now pin (2026-08-15, Option A) - * - * The original version of this file recorded a MEASUREMENT and explicitly - * refused to endorse it: a seeded row stored `'admin'` (the declaration's - * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` - * and skipped every platform default set — the inverse of what #2705 built the - * flag for. It left the product question open: - * - * > are the platform default permission sets meant to be platform-owned - * > (and therefore resyncable), or env-authored (and therefore deliberately - * > left alone)? - * - * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: - * the platform default sets are platform-owned and resyncable, and the seeder - * stamps `managed_by: 'platform'` on its insert. This file no longer records an - * undecided state — it pins the ruling, on BOTH sides of the line the ruling - * drew: - * - * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real - * resync reconciles every default set. - * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries - * `'admin'` and is still SKIPPED, forever and on purpose. The ruling - * forbids a migration: a stored `'admin'` cannot be told apart from a - * genuine Setup takeover, so restamping could silently overwrite a real - * admin's edits on the next resync. Report, don't rewrite. - * - * Section 2 is the half that is easy to lose. Deleting it would leave the - * upgrade path — the one every existing deployment is actually on — with no - * coverage at all, and a later "cleanup" that restamps legacy rows would then - * go green. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -// `sys_user` is what the promotion half enumerates. Registered as the REAL -// declaration so its verdict ("no human users yet") is a genuine reading of an -// empty table rather than a table-missing error `tryFind` swallowed into `[]` — -// the two are indistinguishable from the outside and only one of them is the -// first-boot ordering this file means to reproduce. -import { SysUser } from '@objectstack/platform-objects/identity'; -// The REAL shipped seeder and the REAL shipped declarations. A local -// re-implementation of either would make this a test of the copy, which is -// precisely the gap the file exists to close. -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYSTEM_CTX = { isSystem: true }; - -const engines: ObjectQL[] = []; - -afterEach(async () => { - while (engines.length) { - try { - await engines.pop()?.destroy(); - } catch { - /* noop */ - } - } -}); - -/** - * A fresh engine on its own `:memory:` database, carrying the permission-set - * declaration passed in. `declaration` exists for section 3's counterfactual; - * every case in sections 1 and 2 boots the shipped declaration unmodified. - */ -async function boot(declaration: any = SysPermissionSet): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.security-objects', - name: 'Security Objects', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [declaration, SysUserPermissionSet, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The row as it is physically STORED, read at the driver past every engine-side - * projection. The resync branch reads the engine's view, so both are asserted - * where it matters — a default applied only on read and a value written on - * insert are different facts about the database, and only one of them survives - * a restart. - */ -async function storedRow(engine: ObjectQL, name: string): Promise { - const driver: any = (engine as any).getDriver('sys_permission_set'); - const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); - return rows[0]; -} - -/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ -async function rowViaEngine(engine: ObjectQL, name: string): Promise { - const rows: any[] = await (engine as any).find( - 'sys_permission_set', - { where: { name }, limit: 1 }, - { context: SYSTEM_CTX }, - ); - return rows[0]; -} - -/** - * A row exactly as an install before commit 712e185db holds it — written the way the old - * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes - * from the declaration's `defaultValue: 'admin'` by the very mechanism that - * produced it on every install created before the ruling. - * - * Reproducing the legacy pre-image through its original mechanism rather than - * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real - * declaration, and each case asserts the resulting value before relying on it, - * so a moved default shows up as a failure here instead of quietly turning the - * legacy pin into a test of something else. - */ -async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { - await (engine as any).insert( - 'sys_permission_set', - { - id: `ps_legacy_${ps.name}`, - name: ps.name, - label: ps.label ?? ps.name, - // A deliberately STALE payload: the resync would rewrite these if it ever - // touched the row, so section 2 can prove "left untouched" by content and - // not merely by a counter. - object_permissions: '{}', - field_permissions: '{}', - system_permissions: '[]', - row_level_security: '[]', - tab_permissions: '{}', - active: true, - }, - { context: SYSTEM_CTX }, - ); -} - -function collectingLogger() { - const info: string[] = []; - const warn: string[] = []; - return { - info, - warn, - logger: { - info: (m: string) => info.push(m), - warn: (m: string) => warn.push(m), - }, - }; -} - -describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { - // ─────────────────────────────────────────────────────────────────────────── - // 1. FORWARD — a fresh install after the ruling - // ─────────────────────────────────────────────────────────────────────────── - - it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { - // Every count below is relative to this set. If it were empty, "resync - // reconciled all of them" would be trivially true of nothing — the shape in - // which this whole file could pass while measuring air. - expect(defaultPermissionSets.length).toBeGreaterThan(0); - - const engine = await boot(); - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect(out.seeded).toBe(defaultPermissionSets.length); - }); - - it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { - // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored - // `'admin'` — the declaration's `defaultValue` — because the insert omitted - // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts`, which always has. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - // Physically stored, not merely projected on read: the value survives a - // restart, so every later boot reads it too. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - // …and the same value is what the seeder's own read sees, which is the one - // the resync condition branches on. - expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - it('so `os meta resync` now reconciles EVERY platform default set', async () => { - // The behaviour #2705 built the flag for, finally reachable: resynced == the - // whole shipped set, resyncSkipped 0. Asserted against the live length so - // the pin stays true if a default set is added or removed. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); - - it('a resync of a fresh install warns about nothing', async () => { - // The counterpart to the old measurement, which emitted one bogus - // "intentional override" warn per shipped set. Nothing is skipped now, so - // nothing is warned about. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([]); - }); - - it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { - // Counts alone cannot show the reconcile actually landed. Blank the payload - // on a seeded row, resync, and read the declaration back out of storage. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const driver: any = (engine as any).getDriver('sys_permission_set'); - await driver.knex('sys_permission_set') - .where({ name: 'admin_full_access' }) - .update({ system_permissions: '[]' }); - expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); - - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; - expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) - .toEqual(shipped.systemPermissions ?? []); - // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries - // no `managed_by`, which is what keeps a resync from restamping any row. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 2. LEGACY — a pre-ruling install, which keeps the skip forever - // ─────────────────────────────────────────────────────────────────────────── - - it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { - // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A - // stored `'admin'` is indistinguishable between "the old seeder's field - // default" and "an administrator took this set over in Setup", so it must - // never be reconciled on the strength of a guess. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - - // The pre-image really is what a pre-ruling install holds — asserted, not - // assumed, so this case cannot pass by testing some other row shape. - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - // An upgraded install: the seeder runs, finds the legacy row and leaves it, - // and inserts the remaining sets freshly stamped `'platform'`. - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - // The mixed shape an upgrade produces: everything the platform stamped is - // reconciled, the one legacy row is not. - expect(out.resyncSkipped).toBe(1); - expect(out.resynced).toBe(defaultPermissionSets.length - 1); - - // "Left untouched" proven by CONTENT, not just by a counter: the stale - // payload seeded above is still stale, and provenance was not restamped. - const row = await storedRow(engine, legacy.name); - expect(row.system_permissions).toBe('[]'); - expect(row.object_permissions).toBe('{}'); - expect(row.managed_by).toBe('admin'); - }); - - it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { - // The warn used to end "(intentional override)", which is false for exactly - // these rows: on a pre-ruling install the only writer may have been the - // seeder itself one call earlier. The reworded line states the provenance - // and the action and asserts nothing about anybody's intent. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([ - `[security] resync left ${legacy.name} untouched — row is admin-owned`, - ]); - // Pinned as a substring too, so a future reword cannot quietly reintroduce - // the intent claim in some other sentence shape. - expect(warn.join('\n')).not.toContain('intentional override'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 3. ATTRIBUTION — which link actually supplies the value - // ─────────────────────────────────────────────────────────────────────────── - - /** - * A counterfactual, and note it runs the OPPOSITE way round from the one this - * file carried before the ruling. Back then the seeder wrote no `managed_by`, - * so respelling the declared default flipped the stored value — which is what - * proved the default was the source. Now the seeder stamps explicitly, so the - * declared default must no longer be able to move the value at all. - * - * Predicted direction, written down before running: boot a CLONE of the - * declaration whose `managed_by` default is respelled to `'package'`, seed - * with the same unmodified seeder, and the row must STILL store `'platform'`. - * - * That one reading establishes what section 1 cannot on its own: the value - * comes from the seeder's stamp and not from a declaration that merely - * happens to agree with it. Without this case, respelling - * `defaultValue: 'admin'` to `'platform'` in the object file would make every - * assertion in section 1 pass with the stamp deleted. - */ - it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { - const respelled = { - ...SysPermissionSet, - fields: { - ...(SysPermissionSet as any).fields, - managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, - }, - }; - // Guard: the clone is a copy, and the shipped declaration is untouched by - // building it. Without this the case could be measuring a mutation it made. - // The shipped default stays `'admin'` deliberately — it is the right default - // for a set an admin creates in Setup, and the ruling changed the SEEDER, - // not the declaration. - expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); - - const engine = await boot(respelled); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - - // And the consequence still holds under a default that would otherwise have - // caused a skip: resync reconciles everything. - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts deleted file mode 100644 index 9701a38b887..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapPlatformAdmin — permission-set materialization, focused on the - * insert-once vs `resync` (#2705) split. - * - * The default boot path is insert-once: an existing default permission-set row - * is env-authored config and is never clobbered on restart (so admin Setup - * edits survive). That protection is CORRECT for prod but makes a dev source - * edit silently stale until a `--fresh` wipe. `os meta resync` passes - * `{ resync: true }` to reconcile platform-owned rows to the shipped dist - * without touching business data — while still refusing to overwrite a row an - * admin or a package has explicitly taken over. - */ - -import { describe, it, expect } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { applyManagedWriteDenies } from './managed-object-write-denies.js'; - -/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- - * promotion tables return empty, so promotion short-circuits and we assert the - * seed/resync outcome (carried on every return) directly. */ -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -/** Shipped declaration: member_default now grants `setup.access`. */ -const memberDefault = (over: Record = {}) => - ({ - name: 'member_default', - label: 'Member', - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access'], - ...over, - }) as any; - -const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); - -describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { - it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); - expect(r.resynced).toBe(0); - // Same row, and the shipped `setup.access` did NOT land — this is the - // #2705 boot behavior (protects admin edits; stale in the dev loop). - expect(row(ql)!.id).toBe('ps_old'); - expect(row(ql)!.system_permissions).toBe('[]'); - }); - - it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(1); - expect(r.resyncSkipped).toBe(0); - // Updated in place (no new insert), and the declaration is now live. - expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); - expect(row(ql)!.id).toBe('ps_old'); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { - const ql = makeQl([ - { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); - }); - - it('resync: leaves a package-owned row untouched', async () => { - const ql = makeQl([ - { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - }); - - it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { - const ql = makeQl([]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(row(ql)).toBeTruthy(); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - }); -}); - -// #3325 — the transform runs upstream in runBootstrap; here we confirm that once -// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin -// serializes the injected denies into the seeded object_permissions JSON. -describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { - const schemas = [ - { name: 'sys_sso_provider', managedBy: 'better-auth' }, - { name: 'crm_lead', managedBy: 'platform' }, - ]; - // member_default is a target set; give it the wildcard shape the real one has. - const enriched = () => { - const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; - applyManagedWriteDenies([set], schemas); - return set; - }; - - it('fresh DB: the injected deny serializes into object_permissions', async () => { - const ql = makeQl([]); - await bootstrapPlatformAdmin(ql, [enriched()]); - const perms = JSON.parse(row(ql)!.object_permissions); - expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); - expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied - }); - - it('resync: reconciles a stale platform-owned row that predates the deny', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); - expect(r.resynced).toBe(1); - expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index e67616aba1e..edf2a164d98 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -3,12 +3,8 @@ /** * bootstrapPlatformAdmin — first-boot platform admin promotion. * - * Two responsibilities, both idempotent and run on `kernel:ready`: - * - * 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets` - * entry (admin_full_access / member_default / viewer_readonly). - * - * 2. **Answer the platform-admin question, POSTURE-KEYED** (#11184 ruling + * One responsibility, idempotent and run on `kernel:ready`: **answer the + * platform-admin question, POSTURE-KEYED** (#11184 ruling * 2026-08-23; re-anchored by #11663, maintainer acceptance 2026-08-25, * leg L4 = #11974): * - `single`: promote the first registered human user by inserting a @@ -48,31 +44,12 @@ * `ensureDefaultOrganization`). Install that plugin to get * multi-tenant bootstrap. * - * ## Provenance of the seeded permission-set rows (commit 712e185db, ruled 2026-08-15) - * - * The seed insert stamps `managed_by: 'platform'` **explicitly**, so a fresh - * install's default sets are platform-owned and `os meta resync` reconciles - * them — which is what #2705 built the resync flag for. This also puts the - * seeder in line with its two siblings this package had (the built-in position - * and system capability seeders, both since retired by ADR-0131), which always - * stamped `'platform'` rather than inheriting a default. - * - * ⚠️ **Installs created BEFORE that ruling carry `'admin'` on these rows.** - * The pre-ruling insert omitted `managed_by` altogether, so the value came from - * the declared `defaultValue: 'admin'` in `objects/sys-permission-set.object.ts` - * — measured on a real engine (commit db923a3a8: a seeded row stored `'admin'`, and a real - * resync returned `resynced 0 / resyncSkipped 8`, skipping every shipped - * default set). - * - * For those legacy rows the resync SKIP stands, permanently and by decision: - * a stored `'admin'` is **indistinguishable** between "the old seeder's field - * default" and "an administrator took this set over in Setup". So there is - * deliberately **no migration and no restamp** — rewriting them to `'platform'` - * would make genuine admin customizations reconcilable and could silently - * overwrite them on the next `os meta resync`. Report, don't rewrite. A legacy - * install that wants the platform defaults reconciled has to re-own the rows - * deliberately (or re-seed with `--fresh`); that is an operator's choice to - * make, not one a boot should make on their behalf. + * [ADR-0131 D3/D4] It writes no `sys_permission_set` row. The platform's + * default sets (admin_full_access / member_default / viewer_readonly …) are + * definitions this plugin ships on its manifest and the security catalog + * serves; the promotion grant names its set. The materialization that used to + * seed those rows insert-once — and the `os meta resync` reconcile of them — + * retired with the rest of the catalog seeders. */ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; @@ -85,12 +62,6 @@ import { import { normalizePlatformAdminEmail, resolvePlatformAdminEmails } from '@objectstack/core'; import type { SeedSettlementSnapshot } from '@objectstack/spec/contracts'; import { claimSeedOwnership } from './claim-seed-ownership.js'; -import { - createSeedWriteRefusals, - reportSeedWriteRefusals, - type SeedWriteRefusals, -} from './write-refusals.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; import { resolvePlatformAdminStanding, type PlatformAdminStandingEntry, @@ -105,6 +76,7 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; +import { reportThroughSink } from './seed-refusal-sink.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -219,7 +191,7 @@ async function recordPlatformAdminStandingChange( // for that context to be forgotten — it is also a second row on the // tenant-audit write-call-site census for one write. let insertRefusal: unknown; - await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => { + await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, (e) => { insertRefusal = e; }); if (insertRefusal !== undefined) { @@ -251,24 +223,6 @@ interface BootstrapOptions { */ error?: (message: string, meta?: any) => void; }; - /** - * [#2705] Force re-materialization of the default permission-set rows from - * the compiled declaration. - * - * Default (`false`) keeps the insert-once shape: an existing row is left - * untouched so an admin's Setup customizations survive every restart. This is - * correct for prod boot. - * - * `os meta resync` sets it to `true` to reconcile the DB rows to the shipped - * `dist` after a source edit — the dev loop that insert-once otherwise makes - * silently stale (a changed default set is served with its OLD value until a - * `--fresh` wipe). Only platform-owned rows (`managed_by` absent or - * `'platform'`) are overwritten. Rows carrying any other provenance are left - * alone: `'user'` / `'admin'` (taken over in Setup — or, on an install older - * than commit 712e185db, seeded before the platform stamped its own rows) and - * `'package'` (owned by package metadata). - */ - resync?: boolean; /** * The seed pipeline's tally at the moment this bootstrap runs, read by the * caller through the published `seed-settlement` contract. @@ -277,7 +231,7 @@ interface BootstrapOptions { * the claim pass is the only step here whose answer depends on whether the * platform's own seeder has finished writing, and a pass that cannot say so * reports "claimed 0 of 0" for both "nothing to claim" and "nothing had - * landed yet". Absent for callers with no kernel context (`os meta resync`), + * landed yet". Absent for callers with no kernel context, * which the claim reports as `unattested` rather than guessing. */ seedSettlement?: SeedSettlementSnapshot | undefined; @@ -391,40 +345,22 @@ async function tryFind( } } -// ⛔ The `catch` RECORDS the refusal before it answers, when the caller passed -// a log to record into. Answering `null`/`false` alone is what made a refused -// write indistinguishable from "nothing to do": `seeded` never grows, the pass -// returns normally, and the boot reports a successful seed of zero rows. Still -// no rethrow — this pass reports, it does not decide whether the deployment -// boots. See `reportSeedWriteRefusals` in `write-refusals.ts`. +// Never rethrows — this pass reports, it does not decide whether the +// deployment boots. A caller that must tell a refused write from "nothing to +// do" passes `onRefusal` and is told. async function tryInsert( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, - // Symmetric with {@link tryFind}'s observer, and for the same reason: a - // caller outside the SEED pass needs the refusal itself, not a `null` that - // the seed reporter will later summarize on a channel that is not its own. + ql: any, object: string, data: any, + // Symmetric with {@link tryFind}'s observer, and for the same reason. onRefusal?: (error: unknown) => void, ): Promise { try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch (e) { - refusals?.record(object, e); onRefusal?.(e); return null; } } -async function tryUpdate( - ql: any, object: string, data: any, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: SYSTEM_CTX }); - return true; - } catch (e) { - refusals?.record(object, e); - return false; - } -} - function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); @@ -507,40 +443,6 @@ export function shouldReplayBootstrapFor(opCtx: { return resolvePlatformAdminEmails().emails.length > 0; } -/** - * The platform-owned definition facets of a default permission set — the - * fields the runtime resolver hydrates back into ExecutionContext - * (`resolve-authz-context.ts` → systemPermissions / tabPermissions / object & - * field masks). Single source for both the first-boot insert and the `#2705` - * resync update so the two paths can never drift. Identity/provenance columns - * (`id`, `name`, `active`, `managed_by`, `package_id`) are deliberately NOT - * here — resync reconciles the declaration, never the ownership. - * - * [commit 712e185db] `managed_by` must stay out of this helper even though the seed insert - * now stamps it. Both paths share these fields, so adding it here would make - * every resync RESTAMP the row it reconciles -- silently converting a legacy - * `admin`-owned row (which may be a real Setup takeover) into a platform-owned - * one and clobbering it on that same pass. The insert stamps provenance at its - * own call site precisely so the resync update cannot. - * - * `description` / `adminScope` are read defensively: neither is on the typed - * PermissionSet shape (name/label/objects/fields/...), but both persist when a - * runtime declaration provides them without tripping the dts typecheck. - */ -function platformOwnedFields(ps: PermissionSet): Record { - return { - label: ps.label ?? ps.name, - description: (ps as any).description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row. - admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, - }; -} - /** * The permission set whose unscoped grant makes a user the platform admin under * `single`. Named once so the bootstrap's guard and @@ -753,17 +655,15 @@ export async function findExistingPlatformAdmin( } /** - * Persist seed permission sets and answer the posture-keyed platform-admin - * question: promote the first registered human user under `single`, report - * config-derived standing (and write nothing) under walled postures. Safe to - * call multiple times. + * Answer the posture-keyed platform-admin question: promote the first + * registered human user under `single`, report config-derived standing (and + * write nothing) under walled postures. Safe to call multiple times. */ export async function bootstrapPlatformAdmin( ql: any, bootstrapPermissionSets: PermissionSet[], options: BootstrapOptions = {}, ): Promise<{ - seeded: number; adminPromoted: boolean; reason?: string; /** Count of seeded rows re-owned to the freshly-promoted admin. */ @@ -787,10 +687,6 @@ export async function bootstrapPlatformAdmin( * to get right). */ adminUserId?: string; - /** [#2705] Existing platform-owned rows reconciled to dist under `resync`. */ - resynced?: number; - /** [#2705] Existing rows left untouched by `resync` (admin/package-owned). */ - resyncSkipped?: number; /** * [commit 9b9581b11] WHY this target was chosen, when one was. `declared-owner` means * `OS_PLATFORM_OWNER_EMAIL` named them; `oldest-authenticable` means nobody @@ -810,128 +706,22 @@ export async function bootstrapPlatformAdmin( }> { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, adminPromoted: false, reason: 'objectql_unavailable' }; + return { adminPromoted: false, reason: 'objectql_unavailable' }; } - // 1. Seed permission set rows. - const seeded: Record = {}; - let resynced = 0; - let resyncSkipped = 0; - // One log per pass, not per refused row: a legacy platform-wide unique index - // refuses EVERY default permission set, and a line each would bury the remedy. - const refusals = createSeedWriteRefusals(); - // [#22169] Names whose existence read was REFUSED, so nothing was inserted - // for them — reported once below, after the loop. - const unreadable: string[] = []; - for (const ps of bootstrapPermissionSets) { - if (!ps.name) continue; - // ⛔ "Refused" is not "absent". `tryFind` answers `[]` for both, and this - // loop used to INSERT on both: on a table without the per-organization - // unique index (remote tables built before its retrofit) a refused read - // minted a second row of a set that already existed, and a table holding - // such duplicates can never build that index again. The observer tells - // the two apart; a read that could not answer declines the insert. - let readRefused = false; - const existing = await tryFind( - ql, 'sys_permission_set', { name: ps.name }, 1, undefined, undefined, - () => { readRefused = true; }, - ); - if (readRefused) { - unreadable.push(ps.name); - continue; - } - if (existing.length > 0 && existing[0].id) { - const row = existing[0]; - seeded[ps.name] = row.id; - // Insert-once by default: an existing row is never clobbered on restart, - // which is what protects an admin's Setup edits. Under `resync` - // (`os meta resync`, #2705) reconcile the row to the shipped dist so a dev - // source edit takes effect without `--fresh` -- but only for rows the - // platform still owns. - if (options.resync) { - if (!row.managed_by || row.managed_by === 'platform') { - if (await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...platformOwnedFields(ps) }, refusals)) { - resynced += 1; - } - } else { - resyncSkipped += 1; - // [commit 712e185db] Neutral by ruling: state the provenance and the action, and - // claim NOTHING about intent. This used to say "(intentional - // override)", which is a lie for every row on a pre-ruling install -- - // there the only writer may have been this very seeder one call - // earlier, inheriting `defaultValue: 'admin'` rather than any admin - // deciding anything. The stored value cannot tell the two apart, so - // the log must not pretend it can. - logger?.warn?.( - `[security] resync left ${ps.name} untouched — row is ${row.managed_by}-owned`, - { name: ps.name, managedBy: row.managed_by }, - ); - } - } - continue; - } - const id = genId('ps'); - const created = await tryInsert(ql, 'sys_permission_set', { - id, - name: ps.name, - ...platformOwnedFields(ps), - active: true, - // [commit 712e185db] Stamp provenance EXPLICITLY rather than letting it fall to the - // declaration's `defaultValue: 'admin'`. Without this the platform's own - // default sets are stored indistinguishably from admin-authored ones, so - // `os meta resync` skips every single one of them (measured in commit db923a3a8: - // resynced 0 / resyncSkipped 8) -- the exact inverse of what #2705 built - // the flag for. Matched the built-in position and system capability - // seeders (both since retired by ADR-0131), which stamped `'platform'`. - managed_by: 'platform', - }, refusals); - if (created?.id) seeded[ps.name] = created.id; - else if (created) seeded[ps.name] = id; - } - - // Reported HERE rather than at function end: every `return` below this point - // is an early exit of the PROMOTION half, and the catalog seed above is - // finished either way. Placing it at the end would make the diagnosis - // conditional on how promotion happened to resolve. - reportSeedWriteRefusals(logger, refusals); - if (unreadable.length > 0) { - // [#22169] ONCE per pass with the names, never a line per set: a refused - // read usually means the whole table is unreadable, and a per-name line - // would bury its own remedy. `warn`, through the sink derivation the - // sibling seeders' unreadable lines use, so it is heard with no logger - // injected: nothing was written, so nothing claims to have landed. - reportThroughSink( - logger, - '[security] platform default permission sets left untouched — their sys_permission_set rows could not ' + - 'be read, so none was inserted: a read that did not answer is not an absent row, and inserting on it ' + - 'duplicates a set that may already exist. A set that is genuinely absent is seeded by the next boot ' + - 'whose read answers.', - { unreadable: unreadable.length, total: bootstrapPermissionSets.length, names: unreadable }, - ); - } - - const seededCount = Object.keys(seeded).length; - // [#11532] Under a walled posture these rows are organization-less BY RULING - // (#10103, 2026-08-20) and unreadable through the wall, and the catalog pass - // that runs next reports them once per organization. Saying so HERE is what - // stops the operator's first sight of them being a warning that calls the - // platform's own output legacy state: the fresh walled rig logged - // `seeded: 8` with nothing to indicate the rows carried no organization at - // all. Not a behaviour change — the seeding above is byte-identical. - if (seededCount > 0 && postureEnforcesWall(resolveTenancyPosture())) { - logger?.info?.( - '[security] platform default permission sets seeded WITHOUT an organization (the platform ' + - 'bucket) — ruled 2026-08-20 and unchanged: the platform-admin grant points at the ' + - 'admin_full_access row by id. Under a walled posture they are unreadable through the ' + - 'tenant wall; each organization gets its own copies from the per-organization catalog ' + - 'pass, so no principal is missing a set.', - { seeded: seededCount, names: Object.keys(seeded).sort() }, - ); - } - // Attached to every return below so `os meta resync` can report the reconcile - // outcome even when admin promotion short-circuits (the common dev case: a DB - // that already has an admin returns `already_have_admin`). - const resyncCounts = { resynced, resyncSkipped }; + // 1. [ADR-0131 D3/D4] No catalog row is written: the platform's default + // sets are definitions this plugin ships on its manifest, served from + // the registry, and a grant names its set. The one read left is the + // LEGACY `admin_full_access` row an older release seeded, whose id is how + // leg C of {@link findPlatformAdminGrantHolder} reaches a grant written + // before the name column existed. ⛔ "Refused" is not "absent": a read + // that did not answer cannot say no such grant exists, so the promotion + // is withheld below rather than risk minting a second administrator. + let legacyAdminRowUnreadable = false; + const legacyAdminRows = await tryFind( + ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1, undefined, undefined, + () => { legacyAdminRowUnreadable = true; }, + ); // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): @@ -954,18 +744,28 @@ export async function bootstrapPlatformAdmin( // [ADR-0131 D4] The grant names `admin_full_access`; the set is the catalog // definition this plugin ships, so no `sys_permission_set` row is needed to - // grant it. The seeded row's id, when there is one, feeds only the legacy + // grant it. The legacy row's id, when there is one, feeds only the // restrict-only leg of the holder scan ({@link findPlatformAdminGrantHolder}). if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; + return { adminPromoted: false, reason: 'admin_permission_set_missing' }; } - if (unreadable.includes(PLATFORM_ADMIN_PERMISSION_SET_NAME)) { + if (legacyAdminRowUnreadable) { // [#22169] The set's row table did not answer, so the legacy leg cannot ask // whether an unnamed grant already makes someone the administrator; no - // promotion is made on a question this pass could not ask. - return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_unreadable', ...resyncCounts }; + // promotion is made on a question this pass could not ask. Said through + // the sink derivation, so it is heard with no logger injected. + reportThroughSink( + logger, + '[security] platform-admin promotion WITHHELD: the legacy admin_full_access sys_permission_set row ' + + 'could not be read, so this pass cannot ask whether a grant written before the name column existed ' + + 'already makes someone the platform administrator. Nothing was written; the next pass whose read ' + + 'answers decides.', + { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, + ); + return { adminPromoted: false, reason: 'admin_permission_set_unreadable' }; } - const adminPsId: string | undefined = seeded[PLATFORM_ADMIN_PERMISSION_SET_NAME]; + const adminPsId: string | undefined = + typeof legacyAdminRows[0]?.id === 'string' && legacyAdminRows[0].id !== '' ? legacyAdminRows[0].id : undefined; // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // @@ -1010,7 +810,6 @@ export async function bootstrapPlatformAdmin( // config-derived standing, or to refuse fail-closed when nobody is declared. if (!walled && unscopedHolder) { return { - seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', // The promotion is a no-op forever; the CLAIM is not. The seed-settle @@ -1018,7 +817,6 @@ export async function bootstrapPlatformAdmin( // has run at all, asks {@link findExistingPlatformAdmin}, which answers // with this same holder (see `adminUserId` above). ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), - ...resyncCounts, ...grantScanCounts, }; } @@ -1040,10 +838,8 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { - seeded: seededCount, adminPromoted: false, reason: 'admin_grant_unnamed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1071,7 +867,7 @@ export async function bootstrapPlatformAdmin( // process). The startup half (walled + undeclared ⇒ REFUSE BOOT, naming // the variable) lives in plugin-auth's `init()`; this is the // defense-in-depth line for paths that reach the bootstrap without that - // guard (`os meta resync`, embeddings without plugin-auth). + // guard (embeddings without plugin-auth). // // [#11663 L5] ⛔ This line is no longer skipped when a legacy holder exists. // It used to be, and the reason it used to be has expired: L4's skip was @@ -1106,10 +902,8 @@ export async function bootstrapPlatformAdmin( if (logger?.error) logger.error(message); else logger?.warn?.(message); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_owner_email_undeclared', - ...resyncCounts, ...grantScanCounts, }; } @@ -1142,10 +936,8 @@ export async function bootstrapPlatformAdmin( // CHANGE of standing, plus the first-boot baseline. await recordPlatformAdminStandingChange(ql, standing, logger); return { - seeded: seededCount, adminPromoted: false, reason: 'walled_config_derived', - ...resyncCounts, ...grantScanCounts, }; } @@ -1251,10 +1043,8 @@ export async function bootstrapPlatformAdmin( if (!inserted) { logger?.warn?.(`[security] failed to grant admin_full_access to first user ${chosen.email ?? chosen.id}`); return { - seeded: seededCount, adminPromoted: false, reason: 'insert_failed', - ...resyncCounts, ...grantScanCounts, }; } @@ -1286,12 +1076,10 @@ export async function bootstrapPlatformAdmin( } return { - seeded: seededCount, adminPromoted: true, ownershipClaimed, adminUserId: String(chosen.id), basis: audit.basis, - ...resyncCounts, ...grantScanCounts, }; }; @@ -1454,7 +1242,6 @@ export async function bootstrapPlatformAdmin( if (logger?.warn) logger.warn(message); else logger?.info?.(message); return { - seeded: seededCount, adminPromoted: false, // [commit 9b9581b11, batch #100] The ruling allows either a distinct code or a // fold into `declared_owner_not_authenticable` with verification named @@ -1463,7 +1250,6 @@ export async function bootstrapPlatformAdmin( // reading a structured sink can tell "register a login" apart from // "click the link in your mailbox". reason: unverifiedOnly ? 'declared_owner_not_verified' : 'declared_owner_not_authenticable', - ...resyncCounts, ...grantScanCounts, }; } @@ -1487,7 +1273,7 @@ export async function bootstrapPlatformAdmin( } if (scannedHumans === 0) { logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts, ...grantScanCounts }; + return { adminPromoted: false, reason: 'no_users', ...grantScanCounts }; } if (!target) { // [#14348] Humans exist, but not one of them can sign in. Measured on a @@ -1522,10 +1308,8 @@ export async function bootstrapPlatformAdmin( else logger?.info?.(truncation); } return { - seeded: seededCount, adminPromoted: false, reason: 'no_authenticable_user', - ...resyncCounts, ...grantScanCounts, }; } diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index c4898cbfcc6..ae4bfca26a3 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -561,7 +561,7 @@ export async function claimSeedOwnership( // nor any later object. The rows stay unowned and the next run claims // them, because the predicate is still true of them. That run is the // next `app:seeded` (this boot or a later one) or the next promotion — - // ⚠️ NOT `os meta resync` and NOT a bootstrap replay: on an install that + // ⚠️ NOT a bootstrap replay: on an install that // already has an admin both short-circuit on `already_have_admin` and // never reach this function. logger?.warn?.( diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 9e91bba87ed..463ca038640 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -1677,7 +1677,7 @@ export async function reconcilePermissionSetProjection( '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall, `meta resync`) recreates none ' + + 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ec6d82723f3..fc8fb2beee0 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -812,7 +812,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // // Keyed on an EXPLICIT `dev: true`, never on `factoryDev`'s NODE_ENV // default: `bootSchemaStack` passes no `dev`, and a non-deferred one-shot - // boot (`os meta resync --yes`, the data commands' `--apply`) runs schema + // boot (the data commands' `--apply`) runs schema // sync — under `NODE_ENV=development` it would apply safe drift its // operator never confirmed. The step-down keeps the default; the self-heal // is a serving host's declaration. diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index 0d0a5237658..4c790c33a4f 100644 --- a/skills/objectstack-platform/references/operations.md +++ b/skills/objectstack-platform/references/operations.md @@ -92,7 +92,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): |:--------|:-------------| | `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | | `os diff` | Compare two ObjectStack config files and detect breaking changes | -| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | +| `os meta register` | Register (create/update) metadata on a target server (there is no `os meta apply`) | | `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | ## Environments & deploy From 7f185509939c67088c4934b3b0f4320147b7c40f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:10:15 +0000 Subject: [PATCH 03/39] wip(examples,plugin-security): delete the example binders; drift_status retires with its writer (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- content/docs/permissions/authorization.mdx | 12 +- content/docs/permissions/permission-sets.mdx | 62 +++------ .../areas/access-security.json | 2 +- docs/qa/platform-checklist/areas/search.json | 2 +- examples/app-crm/objectstack.config.ts | 10 -- .../src/security/bind-position-sets.ts | 121 ----------------- .../app-crm/test/position-bindings.test.ts | 18 +-- examples/app-showcase/objectstack.config.ts | 4 - examples/app-showcase/src/data/seed/index.ts | 10 +- .../src/security/bind-position-sets.ts | 123 ------------------ .../src/security/permission-sets.ts | 4 +- .../app-showcase/src/security/positions.ts | 7 +- .../src/security/seed-approval-demo.ts | 6 +- .../app-showcase/test/inert-wirings.test.ts | 18 ++- .../src/objects/rbac-objects.test.ts | 14 -- .../src/objects/sys-permission-set.object.ts | 62 +-------- 16 files changed, 62 insertions(+), 413 deletions(-) delete mode 100644 examples/app-crm/src/security/bind-position-sets.ts delete mode 100644 examples/app-showcase/src/security/bind-position-sets.ts diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 8d5d9470c32..c192857ff4e 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -152,13 +152,11 @@ them, and treat "sensitive field on a public object" as a review smell. The metadata↔config boundary follows one line: **definitions travel with the package (metadata); subject bindings and env-specific values stay as config.** -- A package ships **its own permission sets** (Shape B), recorded with - `managedBy: 'package'` + owning `packageId` on `sys_permission_set` - (`package_id` / `managed_by` columns). `bootstrapDeclaredPermissions` - (`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts`) - seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it - **never clobbers** env-authored (`platform`/`user`/legacy) rows. A package - never writes into a foreign record. +- A package ships **its own permission sets** (Shape B): its + `stack.permissions` are registry items stamped with the owning package + (`_packageId`), served by name from the security catalog (ADR-0131 D3). The + boot writes no `sys_permission_set` row for them, and a package never + writes into a foreign definition. - The environment admin **assigns** sets to positions/users; the runtime unions them. One shared set with hand-picked cross-package grants remains an env-admin-only construct. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index e56b6024100..cabc86aee13 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -161,8 +161,9 @@ the anchor decides *which* unit `unit*` means. ## Built-in permission sets -Auto-seeded on boot by `plugin-security` -(`objects/default-permission-sets.ts`): +Declared by `plugin-security` on its own manifest +(`objects/default-permission-sets.ts`) and served from the security catalog — +the boot writes no row for them: | Name | Scope | What it unlocks | Notes | |:--|:--|:--|:--| @@ -337,9 +338,10 @@ tenant-level. ## Provenance — package vs environment sets (ADR-0086) -A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded -idempotently at boot and re-seeded on upgrade; environment-authored sets -(`platform`/`user`) are never clobbered. The data layer refuses forging package +A package ships its own sets as registry items stamped with the owning package +(`packageId`), served from the security catalog — the boot writes no row for +them, and an upgrade is served as soon as the package registers its new +version; environment-authored sets are never clobbered. The data layer refuses forging package provenance through the admin door (two-doors separation, evolved by ADR-0094 — an edit of a set shipped in code is refused; clone it instead, see below), which is what makes package uninstall well-defined — and enforced: uninstalling a @@ -390,47 +392,21 @@ package and re-publish it (ADR-0086 two-doors). ### Declared ≠ enforced — diagnosing a frozen package set -A package-declared set's *enforced* grants (what `sys_permission_set` stores -and what the evaluator resolves) can diverge from its shipped *artifact* -(what the package currently declares) through two independent mechanisms — -either can be live on one row, and they need different remedies: +A package-declared set's *enforced* grants (what the security catalog serves +and the evaluator resolves) can diverge from its shipped *artifact* (what the +package currently declares) in one way: - **Overlay shadow.** An active `sys_metadata` overlay for the set's name (from a Studio save made while overlaying was still permitted, or through - the `OS_METADATA_WRITABLE` escape hatch) is re-projected onto the record on - every boot, unconditionally — the overlay wins forever, regardless of what - the package ships next. No door creates such an overlay any more: the - metadata door refuses a save over a packaged set, and so does the data door - (clone the set instead). **Remedy:** delete the overlay row. From the - release that adds ADR-0048's cold-boot check, a deployment that still holds - one does not boot: `os migrate security-catalog-overlays` lists every such - row with no server running, and `--apply` deletes them, after which the - package's definition is served again. -- **Provenance skip.** The record's `managed_by` column predates package - provenance tracking (a legacy insert without `managed_by: 'package'` — - typically a database first initialized on an older release line), so boot - seeding treats it as environment-authored and never reconciles it with the - package — permanently. There is currently no automated adoption path for - this case (boot-time auto-adoption and a bulk `os meta - adopt-permission-sets` command were both deliberately deferred as migration - machinery for a customer population that does not yet exist — see the - discussion on the tracking issue for this section). The manual recovery is - the same one-time SQL a field remediation used, run against the - environment's database with a backup first: - - ```sql - UPDATE sys_permission_set - SET managed_by = 'package', package_id = '' - WHERE name = ''; - ``` - - Restart the environment afterward so boot seeding re-adopts the row as - package-owned and reconciles its grants to the shipped artifact. - -Both mechanisms are surfaced on the record itself: `drift_status` -(`'overlay_shadow'` / `'provenance_skip'`) and `drift_detail` name the cause, -recomputed every boot, and the **Needs Attention** list view collects every -currently drifted set — an in-sync set is never listed there. + the `OS_METADATA_WRITABLE` escape hatch) answers ahead of the package's + definition — the overlay wins forever, regardless of what the package ships + next. No door creates such an overlay any more: the metadata door refuses a + save over a packaged set, and so does the data door (clone the set instead). + The boot names every such set in its log. **Remedy:** delete the overlay + row. From the release that adds ADR-0048's cold-boot check, a deployment + that still holds one does not boot: `os migrate security-catalog-overlays` + lists every such row with no server running, and `--apply` deletes them, + after which the package's definition is served again. --- diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 0034176ba3e..e379a6cbc9b 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -809,7 +809,7 @@ "app": "showcase", "requires": [ "showcase_inquiry is OWD private with create granted to the baseline — depth only matters where the baseline stops (permission-sets.ts docblock)", - "personas staffed by granting the sets (sys_user_permission_set) or assigning the bound positions manager/exec/ops (bind-position-sets.ts wires position → set at boot)" + "personas staffed by granting the sets (sys_user_permission_set) or assigning the positions manager/exec/ops (each declares its set in permissionSets, examples/app-showcase/src/security/positions.ts)" ] }, "steps": [ diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 4538380d3a8..f3c3120cc97 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -11,7 +11,7 @@ "provenance": "run #7629 (framework, 2026-08-11) drove search.rls-both-personas to 4/4 PASS on exactly this persona — 3 invoices visible to the member vs 15 to admin. Promoted from prose on that one item to a replayable area recipe by the 17.1.0 sweep (#10236 B1); the Mei/Ada measurements above are from #10257. Step 1 re-pointed invite-first by #21060 after QA run #21056 met the invite_only default (403 SELF_REGISTRATION_CLOSED on an uninvited sign-up).", "app": "showcase", "requires": [ - "a stock dev boot — the position→permission-set bindings are created automatically at startup on `kernel:bootstrapped`, including ['contributor','showcase_contributor'] (examples/app-showcase/src/security/bind-position-sets.ts, POSITION_PERMISSION_SET_BINDINGS). ⛔ Do NOT hand-create a sys_position_permission_set row: it already exists, and a duplicate proves nothing about the merge", + "a stock dev boot — the `contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", "system-context write access for the position binding (the sys_user_position insert) — the same pattern access-security.json's permission-set items already replay", "an isolated boot (own port + file DB, dogfood §0); export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL fact) so unrelated flow noise does not pollute the run", "an admin session to INVITE the step-1 address before step 1: POST /api/v1/auth/organization/invite-member {\"email\": \"\", \"role\": \"member\"}. The stock showcase declares no auth.audience, so its posture is the invite_only default, which answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED; a pending, unexpired sys_invitation row for the address is what admits it (an admin POST /api/v1/auth/admin/create-user is the other door, and answers mustChangePassword:true)" diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 8ff69b242ba..10ffc96fc6b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -18,7 +18,6 @@ import { SalesUserPermissionSet, GuestPortalProfile, } from './src/security/index.js'; -import { registerCrmPositionBindings } from './src/security/bind-position-sets.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; @@ -118,12 +117,3 @@ export default defineStack({ // Seed data data: CrmSeedData, }); - -/** - * [#8060] Ensure the persona position↔permission-set bindings exist after the - * security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors - * app-showcase's own `onEnable` wiring. - */ -export const onEnable = async (ctx: unknown): Promise => { - registerCrmPositionBindings(ctx as Parameters[0]); -}; diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts deleted file mode 100644 index e8b9944d199..00000000000 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#8060] Position ↔ permission-set bindings for the CRM example. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `sales-positions.ts`), and that is what the - * authorization resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet; it is deleted with the last of them, and - * `test/position-bindings.test.ts` holds its list equal to the declarations. - * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The - * history: bindings used to live only as `sys_position_permission_set` rows, - * and this app declared three positions - * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` - * permission set that never met — every persona silently degraded to the - * `everyone` baseline (probe-blocked on every CRM object) until an admin - * hand-assigned the set. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the - * name references cannot resolve. We play the admin's part imperatively — - * inserting each missing binding idempotently (dedup by position+set pair, - * stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only - * AFTER every `kernel:ready` handler (incl. the security bootstrap) has - * settled. - * - * `crm_sales_user` is deliberately NOT marked `isDefault`: that would - * auto-bind it to the `everyone` anchor and grant every user — including - * ones holding none of the three positions — full CRUD on every CRM object, - * which changes the example's security story instead of completing it - * (ruling on #8060). Only the three declared positions get the set. - */ - -export const BINDINGS: ReadonlyArray = [ - ['sales_rep', 'crm_sales_user'], - ['sales_manager', 'crm_sales_user'], - ['finance_approver', 'crm_sales_user'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerCrmPositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar (never true for the - // real runtime `PluginContext`, which always provides one — this branch is - // unreachable in practice, so a microtask deferral is equivalent to the - // macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient - // `setTimeout`/`window`/`node` global: `examples/**` sources reachable from - // `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT - // tsconfig, which declares neither `dom` nor `types: ["node"]`. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - void Promise.resolve().then(run); - } -} diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts index 7756be56f1f..68ea7cead93 100644 --- a/examples/app-crm/test/position-bindings.test.ts +++ b/examples/app-crm/test/position-bindings.test.ts @@ -2,23 +2,23 @@ /** * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their - * positions (`permissionSets`), which the authorization resolver reads. The - * junction binder (`src/security/bind-position-sets.ts`) still writes the same - * pairs for the readers that have not moved off `sys_position_permission_set`; - * until it is deleted, the two must name the same pairs, or those readers - * judge a position by bindings the resolver does not grant. + * positions (`permissionSets`), which the authorization resolver reads; no + * junction row is written for them. Pinned literally so a persona losing its + * set is a red here, not a silently narrower boot. */ import { describe, expect, it } from 'vitest'; -import { BINDINGS } from '../src/security/bind-position-sets.js'; import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; describe('CRM position bindings', () => { - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares crm_sales_user', () => { const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) .sort(); - expect(declared).toHaveLength(3); - expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'finance_approver -> crm_sales_user', + 'sales_manager -> crm_sales_user', + 'sales_rep -> crm_sales_user', + ]); }); }); diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index ce5fefe511b..f1777eba834 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -18,7 +18,6 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; -import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; @@ -293,9 +292,6 @@ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. registerRecalcEndpoint(ctx as Parameters[0]); - // [#2926 ②] Ensure the persona position↔permission-set bindings exist after - // the security bootstraps (cannot be a seed — see bind-position-sets.ts). - registerShowcasePositionBindings(ctx as Parameters[0]); // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: // assign the dev admin the approver positions and launch the signoff flows so // real pending requests land in the inbox (cannot be a seed — see diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 3a24a642188..80abc28c1b5 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -258,12 +258,10 @@ const orgUnits = SeedSchema.parse({ ], }); -// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed -// loader runs before the security bootstrap creates the sys_position / -// sys_permission_set rows, so the required name references cannot resolve. -// They are ensured imperatively on kernel:bootstrapped instead (after every -// kernel:ready handler, incl. the security bootstrap, has settled) — see -// `src/security/bind-position-sets.ts` (wired via `onEnable`). +// [#2926 ②, ADR-0131 D3/D4] Position ↔ permission-set bindings are NOT seeded +// here: each position declares the sets it distributes in `permissionSets` +// (`src/security/positions.ts`), and the authorization resolver reads them +// from the definition — no binding row exists to seed. const teams = defineSeed(Team, { mode: 'upsert', diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts deleted file mode 100644 index 645ccd00166..00000000000 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `positions.ts`), and that is what the authorization - * resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet (the delegated-admin gate, the explainer, the sharing - * recipients); it is deleted with the last of them. - * `test/inert-wirings.test.ts` holds this list equal to the declarations. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name - * references cannot resolve and the required lookups fail validation. So we play - * the admin's part imperatively — inserting each missing binding idempotently - * (dedup by position+set pair, stable ids). - * - * Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in - * registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts - * BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before* - * the security bootstrap has created the position/set rows — the rows never - * appear from inside that hook. We therefore bind on **`kernel:bootstrapped`**, - * the anchor the kernel fires only AFTER every `kernel:ready` handler has - * settled (`kernel.ts` Phase 3.5 / `lite-kernel.ts`), so the bootstrap rows are - * guaranteed present. - * - * `everyone → showcase_member_default` is NOT bound here: the security plugin - * auto-binds the app's `isDefault` set (resolved as its `fallbackPermissionSet`) - * to `everyone` at boot. This list only carries the persona → set bindings the - * framework cannot infer. - */ - -/** - * The persona bindings as junction rows — the same pairs the position - * definitions declare (`inert-wirings.test.ts` pins the equality). - */ -export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ - ['contributor', 'showcase_contributor'], - ['manager', 'showcase_manager'], - ['exec', 'showcase_executive'], - ['auditor', 'showcase_auditor'], - ['ops', 'showcase_ops'], - ['field_ops_delegate', 'showcase_field_ops_delegate'], - ['client_liaison', 'showcase_client_liaison'], - ['client_portal_user', 'showcase_guest_portal'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerShowcasePositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of POSITION_PERMISSION_SET_BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: POSITION_PERMISSION_SET_BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - setTimeout(() => void run(), 0); - } -} diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 631edfd1a0d..62a413f870b 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -72,8 +72,8 @@ export const ContributorPermissionSet = definePermissionSet({ // // No policy below lists `positions`, deliberately. A set's row-level policies // are part of what the set grants, so they hold for EVERY holder of the set, - // whichever way it is held: through the `contributor` position binding - // (bind-position-sets.ts) or granted directly (the delegated admin below may + // whichever way it is held: through the `contributor` position's declared + // `permissionSets` (positions.ts) or granted directly (the delegated admin below may // hand this set out). `positions` narrows a policy to callers holding one of // the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set // every member resolves, not for a set that is itself the grant. diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 297cd2e2401..3a8d66a1bcb 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -10,10 +10,9 @@ * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * * Each position names the permission sets it distributes in `permissionSets` - * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. - * `src/security/bind-position-sets.ts` still writes the same bindings as - * `sys_position_permission_set` rows, for the readers that have not moved off - * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name, + * and no binding row is written for them. `test/inert-wirings.test.ts` pins + * the declared pairs. * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index a0d0b09a7e7..74ff671836d 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -18,9 +18,9 @@ * - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a * request can't be inserted through the generic data API either. * - * So we play the admin's part imperatively, exactly like `bind-position-sets.ts`: - * on `kernel:bootstrapped` (after the security bootstrap has created the - * position/permission rows and the automation engine is wired) we + * So we play the admin's part imperatively: on `kernel:bootstrapped` (after + * every `kernel:ready` handler has settled and the automation engine is + * wired) we * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 1af7f7ca9f0..c5111fab2c9 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -26,7 +26,6 @@ import { validateFunctionalCompleteness } from '@objectstack/lint'; import type { JobHandlerContext } from '@objectstack/runtime'; import { healthFor, sweepProjectHealth } from '../src/automation/jobs/index.js'; import * as jobsModule from '../src/automation/jobs/index.js'; -import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, @@ -705,10 +704,21 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 // readers that have not moved off `sys_position_permission_set`; until it is // deleted the two must name the same pairs, or those readers judge a // position by bindings the resolver does not grant. - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares the permission set its personas hold (ADR-0131 D3/D4)', () => { + // The position definition is the binding the resolver reads; no junction + // row is written for it. Pinned literally so a persona losing its set is a + // red here, not a silently narrower dogfood boot. const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); - expect(declared.length).toBeGreaterThan(0); - expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'auditor -> showcase_auditor', + 'client_liaison -> showcase_client_liaison', + 'client_portal_user -> showcase_guest_portal', + 'contributor -> showcase_contributor', + 'exec -> showcase_executive', + 'field_ops_delegate -> showcase_field_ops_delegate', + 'manager -> showcase_manager', + 'ops -> showcase_ops', + ]); }); it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index eb2f6c7155a..c84d8a3c06d 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -208,20 +208,6 @@ describe('RBAC object canonical names + row actions', () => { ]); }); - it('[field report — rc→GA declared≠enforced surfacing] SysPermissionSet.drift_status is a select naming the two detected causes, and appears in the "Needs Attention" list view', () => { - const f: any = (SysPermissionSet.fields as any).drift_status; - expect(f, 'drift_status field exists').toBeDefined(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['other', 'overlay_shadow', 'provenance_skip']); - - const views: any = SysPermissionSet.listViews; - expect(views.drifted, 'drifted list view exists').toBeDefined(); - expect(views.drifted.columns).toContain('drift_status'); - expect(views.all_permsets.columns).toContain('drift_status'); - }); - it('[A4 #2920] SysPermissionSet.managed_by is a select on the unified platform/package/admin vocab', () => { const f: any = (SysPermissionSet.fields as any).managed_by; expect(f, 'managed_by field exists').toBeDefined(); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index fe388882bfa..33eff4d50b1 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -166,32 +166,10 @@ export const SysPermissionSet = ObjectSchema.create({ data: { provider: 'object', object: 'sys_permission_set' }, // [ADR-0094] Surface provenance + the customized flag so admins can tell // a packaged set (and whether they've overlaid it) from an env set. - // [field report — rc→GA declared≠enforced surfacing] `drift_status` - // alongside them: `customized` alone stays `0` on a row whose - // `managed_by` is itself wrong (the provenance-skip + overlay-shadow - // confound), so it is not a substitute for this column. - columns: ['label', 'name', 'managed_by', 'customized', 'drift_status', 'active', 'updated_at'], + columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, - // [field report — rc→GA declared≠enforced surfacing] The LOUD surface: - // every package-declared set whose enforced grants differ from the - // shipped artifact, naming the set AND the cause (`drift_status`), - // updated every boot by `runPermissionSetDriftDiagnostics`. An in-sync - // set is never in this view — `drift_status` is written `null`, not - // `'in_sync'` (see `permission-set-drift.ts`), so this filter is a data - // fact, not merely a display choice: noise here is what would make the - // next real drift easy to ignore. - drifted: { - type: 'grid', - name: 'drifted', - label: 'Needs Attention', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'managed_by', 'drift_status', 'drift_detail', 'updated_at'], - filter: [{ field: 'drift_status', operator: 'is_not_null' }], - sort: [{ field: 'updated_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, }, fields: { @@ -337,44 +315,6 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Provenance', }), - // [field report — rc→GA declared≠enforced surfacing] Set when this - // package-declared set's ENFORCED grants differ from the shipped - // artifact — `runPermissionSetDriftDiagnostics` (permission-set-drift.ts) - // recomputes it every boot. Absent (null) means enforced == declared, OR - // this set is not currently package-declared — an in-sync set is never - // given a value here, on purpose (a badge that fires on every packaged - // set is noise, and noise is how the next real one gets ignored). - // ⚠️ NOT a substitute for, and not derived from, `customized`: that flag - // is forced `false` on a row whose `managed_by` is itself wrong (the - // provenance-skip + overlay-shadow confound measured on the field- - // reported environment), which is exactly the case this field still - // needs to catch. - drift_status: Field.select({ - label: 'Declaration Drift', - required: false, - readonly: true, - description: - "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + - "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + - "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + - "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + - "shipped artifact, or this set is not currently package-declared.", - options: [ - { value: 'overlay_shadow', label: 'Overlay shadow' }, - { value: 'provenance_skip', label: 'Provenance skip' }, - { value: 'other', label: 'Drift (other)' }, - ], - group: 'Provenance', - }), - - drift_detail: Field.textarea({ - label: 'Drift Detail', - required: false, - readonly: true, - description: 'Human-readable detail for `drift_status` — names the grant-count mismatch and its cause.', - group: 'Provenance', - }), - // ── System ─────────────────────────────────────────────────── id: Field.text({ label: 'Permission Set ID', From 8d2baa41100c38e14fa264f1b587c4c103b543d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:12:53 +0000 Subject: [PATCH 04/39] wip: zero-row boot pin, everyone re-derivation pin, changeset (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 37 +++++ .../src/builtin-positions.boot.test.ts | 59 +++++--- ...ederivation-on-permission-mutation.test.ts | 138 ++++++++++++++++++ .../grant-permission-set-name.writers.test.ts | 4 +- 4 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 .changeset/15204-s6b2-declared-permission-seeders.md create mode 100644 packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md new file mode 100644 index 00000000000..bb0ec525e9b --- /dev/null +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -0,0 +1,37 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/cli': minor +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and `os meta resync` are deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a package-door publish, a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + +- **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. +- **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. +- **`os meta resync` is deleted.** It reconciled the platform-default `sys_permission_set` rows to the shipped declaration; the authorization resolver reads the declaration itself, so an edited default set is served at the next boot with no command. +- `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. +- In `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments no longer name `os meta resync`. Their behaviour is unchanged. + +Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + +- `bootstrapDeclaredPermissions` +- `normalizeManagedByVocab` +- `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` +- `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` + +FROM → TO: + +- FROM reading a platform default or a package's permission set from `sys_permission_set` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`. +- FROM `os meta resync` after editing a default set → TO restarting the server; the edited declaration is served as shipped. Remove the command from any script that calls it. +- FROM `bootstrapPlatformAdmin(ql, sets, { resync: true })` → TO `bootstrapPlatformAdmin(ql, sets)`; there is no row to reconcile. +- FROM reading `sys_permission_set.drift_status` / `drift_detail` or the **Needs Attention** view → TO the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). +- FROM importing any name in the list above → TO deleting the import; there is nothing to call in its place. diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a0291771532..f24a9ed8a92 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -39,7 +39,9 @@ * ## The goldens * * The census and the write ledger: no boot writes a `sys_position` row in any - * scenario, and an existing database keeps exactly the rows it had. The census + * scenario, and an existing database keeps exactly the rows it had. Since + * stage 6b-2 no boot writes a `sys_permission_set` row either, and the grant + * golden holds without one. The census * instrument is proven able to see a row by a positive control (one * organization-authored create, read back as exactly one row). * @@ -183,6 +185,14 @@ interface Booted { ledger: string[]; /** The rows the boot left, one string per row. */ census: string[]; + /** [C3 stage 6b-2] The `sys_permission_set` rows the boot left, by name. */ + permissionSetCensus: string[]; +} + +/** Every `sys_permission_set` row's name and organization, past every scope. */ +async function permissionSetCensus(engine: ObjectQL): Promise { + const rows = (await engine.find('sys_permission_set', { where: {}, limit: 5000, context: SYS })) as any[]; + return rows.map((r) => `${r.name} | ${r.organization_id ?? '-'}`).sort(); } /** Every `sys_position` row, one string per row, past every scope. */ @@ -337,22 +347,18 @@ async function boot(scenario: Scenario): Promise { } recording = false; - return { engine, metadata, ledger, census: await positionCensus(engine) }; + return { + engine, metadata, ledger, + census: await positionCensus(engine), + permissionSetCensus: await permissionSetCensus(engine), + }; } /** The four principals' resolved grants — needs the organization and its memberships. */ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated'): Promise> { - const orgCtx = { isSystem: true, tenantId: ORG }; - // Walled: the organization's own copies of the sets its grants point at - // (the per-organization catalog), cloned from the platform bucket the boot - // just wrote — as the name-column equivalence suite does. - if (posture === 'isolated') { - for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); - const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; - await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); - } - } + // [C3 stage 6b-2] No `sys_permission_set` row exists in any posture: every + // grant below names its set, and the resolver takes the set from the + // security catalog (ADR-0131 D3/D4). // [ADR-0131 D4] The boot's organization-admin backfill already granted it by // name, in every posture — no organization copy of the set is needed — so // the reconcile finds nothing to do. @@ -360,13 +366,9 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated expect(reconciled.action).toBe('noop'); // The member's set, granted through the data door by an administrator. - const [viewer] = await engine.find('sys_permission_set', { - where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, - context: SYS, - }); await engine.insert( 'sys_user_permission_set', - { user_id: 'usr_member', permission_set: (viewer as any).name }, + { user_id: 'usr_member', permission_set: 'viewer_readonly' }, { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, ); @@ -498,6 +500,27 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { expect(await positionCensus(engine)).toEqual(census); }); + // [C3 stage 6b-2] The catalog seeders are retired: no boot writes a + // `sys_permission_set` row — not the platform defaults, not a package's + // sets, not an organization's copies (the late organization included) — + // while the first platform administrator and the organization + // administrator are still granted (the grant golden below) by name. + it('writes no sys_permission_set row, and still grants the first platform admin and the organization admin', async () => { + const { engine, permissionSetCensus: rows } = booted.get(name)!; + expect(rows).toEqual([]); + const holders = (await engine.find('sys_user_permission_set', { where: {}, limit: 100, context: SYS })) as any[]; + const held = holders.map((g) => `${g.user_id} -> ${g.permission_set}`).sort(); + if (scenario.posture === 'single') expect(held).toContain('usr_admin -> admin_full_access'); + if (scenario.organizationAtBoot) { + expect(held.some((h) => h.startsWith('usr_orgadmin -> organization_admin'))).toBe(true); + } + // Positive control: the census sees a row — one insert reads back as one. + await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual(['zz_census_control | -']); + await engine.delete('sys_permission_set', { where: { id: 'ps_census_control' }, context: SYS } as any); + expect(await permissionSetCensus(engine)).toEqual([]); + }); + if (scenario.organizationAtBoot) { it('grants every principal what it was granted before the declarations', async () => { const { engine } = booted.get(name)!; diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts new file mode 100644 index 00000000000..b7f3bd7b0be --- /dev/null +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22801 → A, C3 stage 6b-2] Every landed `permission` mutation derives the + * `everyone` anchor again. + * + * The derivation used to ride the `permission` publish materializer, which + * retired with the declared-permission seeder (ADR-0131 D3: no catalog row is + * written). It now hangs off the protocol's post-persistence mutation signal + * (`onMetadataMutation`), the one choke point every authoring door funnels + * through — and unconditionally on the body: whether an accepted set still + * qualifies is `readAcceptedEveryoneSets`' question, asked of what the catalog + * now serves, so a publish that drops `isDefault` or deletes the set stops it + * granting at once. + * + * Pinned at the wiring: which events re-derive, which do not, and that a + * bootstrap re-run (the first-user replay) never registers a second listener. + * What a derivation then declares is U2's own suite + * (`suggested-audience-bindings.test.ts`, the dogfood acceptance pin). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; + +afterEach(() => { vi.restoreAllMocks(); }); + +type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; + +/** Boot the real plugin over an in-memory engine, with a protocol that exposes the mutation signal. */ +async function boot() { + const tables: Record = {}; + const ql: any = { + registerMiddleware: () => {}, + // Only the identity objects the plugin's boot gate requires. + getSchema: (name: string) => + name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, + registry: { listItems: () => [] }, + find: async (object: string, opts?: any) => { + for (const k of Object.keys(opts?.where ?? {})) { + if (k.startsWith('$')) throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const rows = tables[object] ?? []; + // The caller's bound, applied BY PRESENCE (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows).map((r) => ({ ...r })); + }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return null; + }, + count: async () => 0, + insert: async (object: string, data: any) => { + const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; + (tables[object] ??= []).push(row); + return { ...row }; + }, + update: async (_object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return 0; + }, + }; + const listeners: MutationListener[] = []; + const protocol = { + // Present so the projection wiring takes its awaited seam and adds no + // listener of its own: every listener counted below is this stage's. + registerMutationProjector: vi.fn(), + onMetadataMutation: (listener: MutationListener) => { + listeners.push(listener); + return () => {}; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => null, list: async () => [] }, + protocol, + }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook, + }; + const derive = vi.spyOn(SecurityPlugin.prototype as any, 'declareEveryone').mockResolvedValue(undefined); + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + const fire = async (event: string) => { + for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb(); + }; + /** Let a fire-and-forget listener's promise chain settle. */ + const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; + return { ctx, fire, settle, listeners, derive }; +} + +describe('[#22801 → A] a landed permission mutation re-derives everyone', () => { + it('registers ONE listener, and a bootstrap re-run never adds a second', async () => { + const { ctx, fire, listeners } = await boot(); + await fire('kernel:ready'); + // Positive control: the bootstrap really completed. + expect( + ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), + ).toHaveLength(1); + expect(listeners).toHaveLength(1); + await fire('kernel:ready'); + expect(listeners).toHaveLength(1); + }); + + it('a published or deleted permission derives everyone again — whatever the body said', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(1); + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'deleted' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(2); + }); + + it('a draft, or a mutation of another type, derives nothing', async () => { + const { fire, settle, listeners, derive } = await boot(); + await fire('kernel:ready'); + const before = derive.mock.calls.length; + + listeners[0]!({ type: 'permission', name: 'crm_sales_user', state: 'draft' }); + listeners[0]!({ type: 'position', name: 'sales_rep', state: 'active' }); + listeners[0]!({ type: 'object', name: 'crm_lead', state: 'active' }); + await settle(); + expect(derive.mock.calls.length - before).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index b34933df9e1..7262d5eb04b 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -93,8 +93,8 @@ describe('[ADR-0131 D4] the grant writers in plugin-security write the set by na expect(grants).toHaveLength(1); expectNamedOnly(grants[0], 'admin_full_access'); - // The holder is read by name: with every catalog row gone it still answers. - await engine.delete('sys_permission_set', { where: {}, multi: true, context: SYS }); + // [C3 stage 6b-2] The bootstrap wrote no catalog row, and the holder is + // read by name with none. expect(await engine.find('sys_permission_set', { context: SYS })).toEqual([]); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_first'); }); From facb9fd38f71f491c2314a4e9d2a4cd567f96b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:19:04 +0000 Subject: [PATCH 05/39] chore(plugin-security): regenerate the translation bundles without drift_status / drift_detail (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../src/translations/en.objects.generated.ts | 16 ---------------- .../src/translations/es-ES.objects.generated.ts | 16 ---------------- .../es-ES.source-hashes.generated.ts | 8 -------- .../src/translations/ja-JP.objects.generated.ts | 16 ---------------- .../ja-JP.source-hashes.generated.ts | 8 -------- .../src/translations/zh-CN.objects.generated.ts | 16 ---------------- .../zh-CN.source-hashes.generated.ts | 8 -------- 7 files changed, 88 deletions(-) diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index cfdc7def891..efbd77c2f66 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -241,19 +241,6 @@ export const enObjects: NonNullable = { label: "Customized", help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "Permission Set ID" }, @@ -273,9 +260,6 @@ export const enObjects: NonNullable = { }, all_permsets: { label: "All" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 745b3549a79..9e7c04cca4e 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -241,19 +241,6 @@ export const esESObjects: NonNullable = { label: "Personalizado", help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "ID de conjunto de permisos" }, @@ -273,9 +260,6 @@ export const esESObjects: NonNullable = { }, all_permsets: { label: "Todos" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index e092e39af7d..608ee0175cd 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index e8d0fda6fd1..d540be40ba6 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -241,19 +241,6 @@ export const jaJPObjects: NonNullable = { label: "カスタマイズ済み", help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "権限セット ID" }, @@ -273,9 +260,6 @@ export const jaJPObjects: NonNullable = { }, all_permsets: { label: "すべて" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index e60a8172406..51efd73cf9b 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 093d7714a18..4859358e9c9 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -241,19 +241,6 @@ export const zhCNObjects: NonNullable = { label: "已定制", help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" }, - drift_status: { - label: "Declaration Drift", - help: "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync treats it as environment-authored and never reconciles it with the package (no automated fix — file an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the shipped artifact, or this set is not currently package-declared.", - options: { - overlay_shadow: "Overlay shadow", - provenance_skip: "Provenance skip", - other: "Drift (other)" - } - }, - drift_detail: { - label: "Drift Detail", - help: "Human-readable detail for `drift_status` — names the grant-count mismatch and its cause." - }, id: { label: "权限集 ID" }, @@ -273,9 +260,6 @@ export const zhCNObjects: NonNullable = { }, all_permsets: { label: "全部" - }, - drifted: { - label: "Needs Attention" } }, _actions: { diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index 64766ab31ac..f97c9467f5f 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,14 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_permission_set._views.drifted.label": "f7b9325c31edc552", - "objects.sys_permission_set.fields.drift_detail.help": "3572261f6d3bc8ab", - "objects.sys_permission_set.fields.drift_detail.label": "a84ebd221c37d10b", - "objects.sys_permission_set.fields.drift_status.help": "168e382c7568b1f3", - "objects.sys_permission_set.fields.drift_status.label": "7946a54138690b86", - "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", - "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", - "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", From 47f0b55d583880c85479036a8e1474225edc54ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:27:01 +0000 Subject: [PATCH 06/39] test(plugin-security): fixtures modelling an existing database write the legacy platform-bucket rows themselves (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../__tests__/legacy-catalog-rows.testkit.ts | 58 +++++++++++++++++++ ...latform-admin-authenticable-target.test.ts | 3 + ...platform-admin-promotion-selection.test.ts | 3 + ...grant-permission-set-name-backfill.test.ts | 3 + ...nt-permission-set-name.equivalence.test.ts | 3 + .../src/grant-readers-by-name.golden.test.ts | 3 + .../src/grant-readers-unnamed-grant.test.ts | 11 ++++ ...lve-authz-grant-set-by-name.golden.test.ts | 3 + .../resolve-authz-grant-set-by-name.test.ts | 3 + 9 files changed, 90 insertions(+) create mode 100644 packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts new file mode 100644 index 00000000000..f73aed75f95 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the organization-less `sys_permission_set` rows an EARLIER + * release's platform-admin bootstrap seeded for the platform's default sets + * (the "platform bucket"). + * + * [C3 stage 6b-2] No boot writes these rows any more (ADR-0131 D3: the sets are + * served from the security catalog, and a grant names its set). A suite that + * models an EXISTING database — a grant written before the name column, whose + * `permission_set_id` points at one of these rows; an organization's copy cloned + * from the bucket — writes them itself, here, in the shape the retired seeder + * wrote: `managed_by: 'platform'`, active, the set's facets as JSON columns. + * Idempotent: a name that already has an organization-less row is left alone. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; + +/** The legacy row id for a default set — stable, so a fixture can point a grant at it. */ +export function legacyBucketRowId(name: string): string { + return `ps_bucket_${name}`; +} + +/** Write the legacy platform-bucket row for each set; returns `name → id`. */ +export async function seedLegacyPlatformBucket( + engine: { find: (...args: any[]) => Promise; insert: (...args: any[]) => Promise }, + sets: readonly PermissionSet[], +): Promise> { + const ids: Record = {}; + for (const ps of sets) { + if (!ps.name) continue; + const existing = ((await engine.find('sys_permission_set', { where: { name: ps.name }, context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own?.id) { + ids[ps.name] = String(own.id); + continue; + } + const id = legacyBucketRowId(ps.name); + await engine.insert('sys_permission_set', { + id, + name: ps.name, + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + active: true, + managed_by: 'platform', + }, { context: SYS }); + ids[ps.name] = id; + } + return ids; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 42c76eb2cad..bfcac098284 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -50,6 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -319,6 +320,8 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC // written by hand — which is exactly the row the old selector would have // picked. const { logger: seedLogger } = collectingLogger(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const seedPass = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger: seedLogger, }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 0d0fec1689c..095c5a6c164 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -70,6 +70,7 @@ import { PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -744,6 +745,8 @@ describe('#16682 — the promotion target is chosen, not sampled', () => { // this change's to make. A declared owner must not move an existing grant. const engine = await boot(); await seedUser(engine as any, 'usr_person0', 'person0@demo.example', '2025-01-01T00:00:00.000Z', false); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const first = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); expect(first.reason).toBe('no_authenticable_user'); const sets = await findRows(engine, 'sys_permission_set', { name: 'admin_full_access' }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..9ba81844b02 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -34,6 +34,7 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -175,6 +176,8 @@ async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise< for (const ps of [...defaultPermissionSets, QA_ADMIN, OWN_SET, OTHER_SET]) { engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const seeded = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(seeded.adminPromoted).toBe(false); // no user yet — the golden world promotes after its users exist diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..c170cf74428 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -44,6 +44,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -158,6 +159,8 @@ async function grantsByPrincipal(posture: Posture): Promise => describe('[ADR-0131 D4] a grant that names nothing — explain', () => { it('reports no dropped grant for it: an unnamed grant names no set to report', async () => { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_member', '2025-03-01T00:00:00.000Z'); await engine.insert('sys_permission_set', { id: 'ps_off', name: 'legacy_reports', label: 'Legacy' }, { context: SYS } as any); @@ -178,6 +181,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst */ async function standingAdminUnnamed(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); expect((await bootstrapPlatformAdmin(engine, defaultPermissionSets)).reason).toBe('no_users'); await user(engine, 'usr_oldest', '2024-01-01T00:00:00.000Z'); await user(engine, 'usr_admin', '2025-01-01T00:00:00.000Z'); @@ -192,6 +197,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('control — named, the grant is the holder: no promotion, and it names the claim target', async () => { const engine = await standingAdminUnnamed(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_admin' }); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_admin'); @@ -200,6 +207,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('unnamed, the promotion is WITHHELD — no second unscoped grant is minted for the oldest account', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report.adminPromoted).toBe(false); expect(report.reason).toBe('admin_grant_unnamed'); @@ -217,6 +226,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst describe('[ADR-0131 D4] a grant that names nothing — the organization-administrator reconcile', () => { async function orgAdmin(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_orgadmin', '2025-02-01T00:00:00.000Z'); await engine.insert( diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..e02cbb25708 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -38,6 +38,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -156,6 +157,8 @@ async function resolverByPrincipal(posture: Posture): Promise Date: Sun, 11 Oct 2026 19:27:46 +0000 Subject: [PATCH 07/39] docs(changeset): state the migration in prose under the runtime-interface-only disposition (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index bb0ec525e9b..553947e4d15 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -9,7 +9,7 @@ feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -28,10 +28,10 @@ Removed from `@objectstack/plugin-security`'s public exports, with no replacemen - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` -FROM → TO: +What to change: -- FROM reading a platform default or a package's permission set from `sys_permission_set` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`. -- FROM `os meta resync` after editing a default set → TO restarting the server; the edited declaration is served as shipped. Remove the command from any script that calls it. -- FROM `bootstrapPlatformAdmin(ql, sets, { resync: true })` → TO `bootstrapPlatformAdmin(ql, sets)`; there is no row to reconcile. -- FROM reading `sys_permission_set.drift_status` / `drift_detail` or the **Needs Attention** view → TO the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). -- FROM importing any name in the list above → TO deleting the import; there is nothing to call in its place. +- Read a platform default or a package's permission set through the security catalog (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`, not from `sys_permission_set`. +- Remove `os meta resync` from any script that calls it. After editing a default set, restart the server: the edited declaration is served as shipped. +- Call `bootstrapPlatformAdmin(ql, sets)` without the `resync` option; there is no row to reconcile. +- In place of `sys_permission_set.drift_status` / `drift_detail` and the **Needs Attention** view, read the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). +- Delete any import of the names listed above; there is nothing to call in their place. From 173f4c1f1f1554a822bcd5e95f80df6dc30c4393 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:28:01 +0000 Subject: [PATCH 08/39] docs(changeset): no-migration-prescription disposition (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .changeset/15204-s6b2-declared-permission-seeders.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index 553947e4d15..1f81ecb718d 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -9,7 +9,7 @@ feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -27,11 +27,3 @@ Removed from `@objectstack/plugin-security`'s public exports, with no replacemen - `normalizeManagedByVocab` - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` - -What to change: - -- Read a platform default or a package's permission set through the security catalog (`createSecurityCatalogReader`) or `GET /api/v1/meta/permission/:name`, not from `sys_permission_set`. -- Remove `os meta resync` from any script that calls it. After editing a default set, restart the server: the edited declaration is served as shipped. -- Call `bootstrapPlatformAdmin(ql, sets)` without the `resync` option; there is no row to reconcile. -- In place of `sys_permission_set.drift_status` / `drift_detail` and the **Needs Attention** view, read the boot log's packaged-overlay report, which names every set an environment overlay shadows (`os migrate security-catalog-overlays` lists them offline). -- Delete any import of the names listed above; there is nothing to call in their place. From 6e67a32803ff376279aa28dfc0f202432cc45043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:31:02 +0000 Subject: [PATCH 09/39] chore: liveness verdicts and the tenant-audit census follow the retired seeders (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 16 ++++----- packages/spec/liveness/permission.json | 13 ++++--- .../spec/liveness/state-counts/permission.md | 2 +- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1d30a19aa13..e1d7b0d6079 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 44 of them — just under a fifth +typed `any` have no type to read, and there are 42 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 232 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 230 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **232** | +| 175 write call sites | quoted in the merged changeset | **230** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 232** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 127 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 230** decidable, **73** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 44 sites reached +one, and both are counted in the generated tables below: the 42 sites reached through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -127 of 232 (55%) as decidably elevated, with 97 more whose elevation is a +125 of 230 (54%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 230`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,17 +223,17 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **232** | +| write call sites on the application surface | **230** | | …whose object name is statically decidable | 157 | -| …whose object name is chosen at run time | 75 | +| …whose object name is chosen at run time | 73 | | …against an object with tenancy ENABLED | 156 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 169 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 127 | +| threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | @@ -241,12 +241,12 @@ cannot read, and they are neither in nor out. | :--- | ---: | | receiver carried a readable engine type | 188 | | receiver erased, placed by the object NAME | 30 | -| receiver erased, placed by an `object: string` PARAMETER | 9 | +| receiver erased, placed by an `object: string` PARAMETER | 7 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 105 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 13 | +| object name is an `object: string` parameter | 11 | | object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `173f4c1f1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index e296a8a5c67..4f190d5d320 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 232 | +| Write call sites | 230 | | Object name statically decidable | 157 | -| Object name chosen at run time | 75 | +| Object name chosen at run time | 73 | | Against a tenancy-enabled object | 156 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 169 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 127 | +| Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `5d0a4a152`. +Measured on 2026-10-11 at `173f4c1f1`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 633 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 629 | +| engine-shaped types recognised | 71 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | @@ -160,13 +160,11 @@ Measured on 2026-10-11 at `5d0a4a152`. | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 2906221a2b9..f5aa8eb1fda 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -22,15 +22,14 @@ }, "packageId": { "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (author-declared fallback for the registry `_packageId` provenance; persisted to sys_permission_set.package_id)", - "note": "ADR-0086 D3 — owning package for a package-shipped set; proven by packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts." + "verifiedAt": "2026-10-11", + "evidence": "packages/cli/src/utils/permission-set-name-collisions.ts (`asId(ps.packageId)` — the author-declared owner a declared set is judged by at the compile-time name-collision door)", + "note": "ADR-0086 D3 — owning package for a package-shipped set. REPOINTED 2026-10-11 (#15204 stage 6b-2): the declared-permission seeder that read it as the `_packageId` fallback and persisted it to sys_permission_set.package_id is retired (ADR-0131 D3: the boot writes no catalog row); the compile-time collision door is the reader that remains." }, "managedBy": { - "status": "live", - "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", - "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." + "status": "dead", + "verifiedAt": "2026-10-11", + "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user). VERDICT CORRECTED 2026-10-11 (#15204 stage 6b-2): no code reads the AUTHORED key. The cited consumer, bootstrapDeclaredPermissions, stamped `managed_by: 'package'` on every row it wrote and never read `ps.managedBy`, so this row's `live` verdict pointed at a writer of the COLUMN, not a reader of the key; that seeder is now retired (ADR-0131 D3: the boot writes no catalog row). The sys_permission_set.managed_by column is unaffected. Retirement of the authored key is an enforce-or-remove item for the spec seat." }, "adminScope": { "status": "live", diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md index 729ecb82e76..346eb180cfe 100644 --- a/packages/spec/liveness/state-counts/permission.md +++ b/packages/spec/liveness/state-counts/permission.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `permission` | 38 | 0 | 0 | 4 | 0 | 42 | +| `permission` | 37 | 0 | 0 | 5 | 0 | 42 | From c89e15de6b815dbd5623ef230c094f20db7ea69b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:35:19 +0000 Subject: [PATCH 10/39] chore: the engine-double ledgers drop the retired suites and pin the new one (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- ...ederivation-on-permission-mutation.test.ts | 32 ++++++++++++++---- scripts/engine-double-contract.baseline.json | 16 --------- scripts/engine-double-contract.pinned.json | 33 +++---------------- scripts/objectql-double-limit.baseline.json | 15 --------- 4 files changed, 30 insertions(+), 66 deletions(-) diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts index b7f3bd7b0be..4f0f4999f5a 100644 --- a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -27,6 +27,28 @@ afterEach(() => { vi.restoreAllMocks(); }); type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; +/** + * Field equality plus `{ $in: [...] }`. Anything else is REFUSED rather than + * answered — a combinator read as a field name matches no row and would report + * that as "no rows". + */ +function rowMatches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) { + throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); + } + const actual = row?.[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = Object.keys(v as Record); + if (ops.length === 1 && ops[0] === '$in') { + return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + } + throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); + } + return actual === (v ?? null); + }); +} + /** Boot the real plugin over an in-memory engine, with a protocol that exposes the mutation signal. */ async function boot() { const tables: Record = {}; @@ -37,12 +59,10 @@ async function boot() { name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, registry: { listItems: () => [] }, find: async (object: string, opts?: any) => { - for (const k of Object.keys(opts?.where ?? {})) { - if (k.startsWith('$')) throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } - const rows = tables[object] ?? []; - // The caller's bound, applied BY PRESENCE (`check:objectql-double-limit`). - return (typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows).map((r) => ({ ...r })); + const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); + // The caller's bound, applied BY PRESENCE and after the filter + // (`check:objectql-double-limit`). + return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); }, findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index bdf92642bfb..e746851c221 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -384,22 +384,6 @@ "why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.", "closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 25. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "delete", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8ff5b60bf3a..8f16f1590a8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3111,11 +3111,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/check-only-write-scope.test.ts", "verb": "delete", @@ -3172,22 +3167,22 @@ "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "findOne", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts", + "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", "verb": "update", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", "verb": "findOne", "pinned": 1 }, { - "file": "packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts", + "file": "packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts", "verb": "update", "pinned": 1 }, @@ -3291,11 +3286,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/per-organization-catalog.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts", "verb": "delete", @@ -3316,11 +3306,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-drift.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", "verb": "findOne", @@ -3331,11 +3316,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-name-collision.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "findOne", @@ -3471,11 +3451,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/walled-platform-bucket-diagnostic.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 9a37c3fd650..dfdb07c3ca8 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -582,27 +582,15 @@ "packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { - "blind": 1 - }, - "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts": { "blind": 1 }, "packages/plugins/plugin-security/src/explain-engine.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/normalize-managed-by.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { "unjudged": 1 }, @@ -612,9 +600,6 @@ "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/permission-set-drift.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { "unjudged": 1 }, From 274040fb3763332bdd8898a257c43d6530e1899d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:45:49 +0000 Subject: [PATCH 11/39] docs(changeset): os meta resync and the example binders carry their own changeset (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../15204-s6b2-declared-permission-seeders.md | 9 ++------- .../15204-s6b2a-meta-resync-example-binders.md | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 .changeset/15204-s6b2a-meta-resync-example-binders.md diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/15204-s6b2-declared-permission-seeders.md index 1f81ecb718d..97b7b7fbb06 100644 --- a/.changeset/15204-s6b2-declared-permission-seeders.md +++ b/.changeset/15204-s6b2-declared-permission-seeders.md @@ -1,15 +1,12 @@ --- '@objectstack/plugin-security': minor -'@objectstack/cli': minor -'@objectstack/driver-sql': patch -'@objectstack/runtime': patch --- -feat(plugin-security,cli)!: the boot writes no `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and `os meta resync` are deleted +feat(plugin-security)!: the boot writes no `sys_permission_set` row — the declared-permission seeder and the platform-default materialization are deleted Clause-②: yes (narrowing) - + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. @@ -17,9 +14,7 @@ Clause-②: yes (narrowing) - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. -- **`os meta resync` is deleted.** It reconciled the platform-default `sys_permission_set` rows to the shipped declaration; the authorization resolver reads the declaration itself, so an edited default set is served at the next boot with no command. - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. -- In `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments no longer name `os meta resync`. Their behaviour is unchanged. Removed from `@objectstack/plugin-security`'s public exports, with no replacement: diff --git a/.changeset/15204-s6b2a-meta-resync-example-binders.md b/.changeset/15204-s6b2a-meta-resync-example-binders.md new file mode 100644 index 00000000000..eb592f48db0 --- /dev/null +++ b/.changeset/15204-s6b2a-meta-resync-example-binders.md @@ -0,0 +1,18 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-security': patch +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(cli)!: `os meta resync` is deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + +In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. From 7d870d107f127a7c7f4fa40957cf5de43fad7932 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:21:38 +0000 Subject: [PATCH 12/39] test(dogfood): fixtures read the security catalog, not a seeded sys_permission_set row; existing-database cases write the legacy row themselves (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../activity-field-values.dogfood.test.ts | 4 +- ...tivity-label-display-field.dogfood.test.ts | 4 +- .../activity-parent-read-gate.dogfood.test.ts | 5 +- .../activity-text-predicate.dogfood.test.ts | 4 +- .../activity-withheld-update.dogfood.test.ts | 5 +- ...n-ledger-decision-metadata.dogfood.test.ts | 4 +- ...-engine-middleware-objects.dogfood.test.ts | 5 +- ...oval-snapshot-masked-field.dogfood.test.ts | 5 +- .../qa/dogfood/test/armed.dogfood.test.ts | 22 +-- ...achments-permission-matrix.dogfood.test.ts | 7 +- ...ments-unscoped-delete-gate.dogfood.test.ts | 15 +- ...audit-log-audit-capability.dogfood.test.ts | 9 +- .../audit-log-field-values.dogfood.test.ts | 4 +- ...audit-log-parent-read-gate.dogfood.test.ts | 5 +- .../authored-row-write-scope.dogfood.test.ts | 5 +- .../dogfood/test/authz-conformance.matrix.ts | 6 +- .../test/bulk-widener-probe.dogfood.test.ts | 7 +- .../qa/dogfood/test/catalog-permission-set.ts | 63 +++++++ .../cbp-explain-master-write.dogfood.test.ts | 9 +- ...t-attachment-comment-gates.dogfood.test.ts | 5 +- ...comments-permission-matrix.dogfood.test.ts | 5 +- .../explain-write-door-parity.dogfood.test.ts | 5 +- ...tity-admin-fields-org-peer.dogfood.test.ts | 5 +- ...lic-read-write-write-floor.dogfood.test.ts | 5 +- ...ner-anchor-and-bulk-writes.dogfood.test.ts | 5 +- ...-write-refusal-not-visible.dogfood.test.ts | 5 +- ...sion-set-assignment-window.dogfood.test.ts | 5 +- ...clone-boot-unowned-warning.dogfood.test.ts | 166 ------------------ ...on-set-lock-row-provenance.dogfood.test.ts | 7 +- ...te-through-package-binding.dogfood.test.ts | 4 + ...ite-unreadable-not-matched.dogfood.test.ts | 5 +- ...arch-companion-field-scope.dogfood.test.ts | 4 +- ...aring-rule-org-less-caller.dogfood.test.ts | 5 +- ...howcase-agent-intersection.dogfood.test.ts | 5 +- ...se-client-liaison-fixtures.dogfood.test.ts | 5 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 5 +- .../showcase-expand-crud-gate.dogfood.test.ts | 5 +- ...owcase-fls-read-mask-strip.dogfood.test.ts | 5 +- ...ase-invoice-seed-isolation.dogfood.test.ts | 5 +- ...case-permission-projection.dogfood.test.ts | 6 + ...howcase-permission-seeding.dogfood.test.ts | 52 +++--- .../showcase-permission-zoo.dogfood.test.ts | 33 ++-- .../test/two-doors-permission.dogfood.test.ts | 42 +---- ...or-unreadable-is-not-found.dogfood.test.ts | 5 +- 44 files changed, 250 insertions(+), 337 deletions(-) create mode 100644 packages/qa/dogfood/test/catalog-permission-set.ts delete mode 100644 packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts diff --git a/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts b/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts index 7225eae5780..84287515912 100644 --- a/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-field-values.dogfood.test.ts @@ -47,6 +47,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'afv_item'; const CAP_UNMASK = 'afv_unmask'; @@ -177,8 +178,7 @@ describe('[#21081] activity rows serve a parent field value only to a reader ser const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts b/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts index 6c8384ca065..89f1f76a088 100644 --- a/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-label-display-field.dogfood.test.ts @@ -39,6 +39,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const CUSTOMER = 'ald_customer'; const NAMED = 'ald_named'; @@ -142,8 +143,7 @@ describe('[#21878] an activity row names its record by the ADR-0079 title, with token[who] = await stack.signUp(email); const userId = await idOf('sys_user', { email }); for (const name of PRINCIPALS[who]) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts index d1a4904ea5c..53dcb56c02a 100644 --- a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts @@ -25,6 +25,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const MEMBER_EMAIL = 'act-gate-member@verify.test'; @@ -63,8 +64,8 @@ describe('sys_activity: a member reads the activity of records it can read, and ql = await stack.kernel.getServiceAsync('objectql'); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER_EMAIL }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: activityReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, activityReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: set.name }, { context: { ...SYS } }); await assertArmed([ diff --git a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts index fbb46afc401..88bdaa74d05 100644 --- a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts @@ -57,6 +57,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'atp_item'; const APPR = 'atp_appr'; @@ -267,8 +268,7 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } if (who === 'control') { diff --git a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts index be764597bf5..b460b041219 100644 --- a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts @@ -33,6 +33,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SysUser } from '@objectstack/platform-objects/identity'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DOMAIN = 'withheld-update-21388.verify.test'; @@ -112,8 +113,8 @@ describe('[#21388] an org peer is withheld the activity rows of a colleague’s uid[who] = String((await plain('sys_user', { email }, 1))[0]?.id ?? ''); await memberOf(uid[who], 'member'); } - const [activitySet] = await plain('sys_permission_set', { name: activityReadSet.name }, 1); - expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + const activitySet = await catalogPermissionSet(ql, activityReadSet.name); + expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const who of ['member', 'admin']) { await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); } diff --git a/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts index 35bb018d99d..69ed72cef8e 100644 --- a/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts @@ -71,6 +71,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const LEDGER = 'sys_audit_log'; const USER = 'sys_user'; @@ -264,8 +265,7 @@ describe('the admin identity rows on the compliance ledger carry decisions, neve token[who] = await stack.signUp(email); const userId = await idOf(USER, { email }); for (const name of READERS[who]) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts b/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts index a32d659a504..eacbc805faf 100644 --- a/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts @@ -45,6 +45,7 @@ import { FILE_BYTES, type AttachmentsHarness, } from './fixtures/attachments-authz-harness.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; type Row = Record; @@ -124,8 +125,8 @@ describe('[#21080] comment threads and activity rows: the analytics door answers adminTok = await stack.signIn(); memberTok = await stack.signUp('mw-objects-member@verify.test'); const memberId = (await ql.findOne('sys_user', { where: { email: 'mw-objects-member@verify.test' }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: activityReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, activityReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts index 9a8888c705e..7421d33b481 100644 --- a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts @@ -42,6 +42,7 @@ import { defineStack, defineFlow } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJECT = 'snapmask_item'; const POSITION = 'snapmask_reviewer'; @@ -197,8 +198,8 @@ describe('[#20964] approval snapshot field served masked on the data plane', () await ql.insert('sys_position', { id: 'pos_snapmask', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_m', user_id: memberId, position: POSITION, organization_id }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_u', user_id: unmaskerId, position: POSITION, organization_id }, SYS); - const unmask = await ql.findOne('sys_permission_set', { where: { name: unmaskSet.name }, context: { isSystem: true } }); - expect(unmask?.id, 'fixture permission set seeded').toBeTruthy(); + const unmask = await catalogPermissionSet(ql, unmaskSet.name); + expect(unmask, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [unmaskerId, submitterId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmask.name }, SYS); } diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index e708393c9fc..36aade5c521 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -238,10 +238,12 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('[control] finds a set that really seeded — so its negative reading is a real negative', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a permission set that failed to seed grants the principal nothing', + // [C3 stage 6b-2] A row that really landed: the member this suite + // signed up. No boot writes a `sys_permission_set` row any more. + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }).read(); expect(verdict.armed).toBe(true); expect(verdict.observed).toContain('is seeded'); @@ -262,8 +264,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED when the row exists but fails its own predicate', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, armed: () => false, control: 'a seeded row that must also carry something', disarmedBy: 'the row seeded but not the part the control rides on', @@ -383,10 +385,10 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( }), seededArmed({ stack: inside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a set that failed to seed grants the principal nothing', + object: 'sys_user', + where: { email: 'armed-inside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }), ]), ).resolves.toBeUndefined(); diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index f1e1fe3d5bc..1d072ec2a10 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -46,6 +46,7 @@ import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/ import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js'; import { assertArmed, principalArmed } from './armed.js'; import { SysAttachment } from '@objectstack/platform-objects/audit'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DAY_MS = 86_400_000; @@ -163,8 +164,8 @@ describe('attachments permission matrix (#2755)', () => { // Domain grant (see attachmentManagerSet): memberA/memberB manage // attachments; the baseline member deliberately keeps only the // `member_default` everyone-anchor baseline (no delete bit anywhere). - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } @@ -719,7 +720,7 @@ describe('attachments without service-storage (a′)', () => { const editorTok = await stack.signUp('att-nostorage-b@verify.test'); const uploaderId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-a@verify.test' }, context: SYS }))?.id; const editorId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-b@verify.test' }, context: SYS }))?.id; - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); for (const userId of [uploaderId, editorId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts index fd05f397eea..cf672abbae9 100644 --- a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts @@ -58,6 +58,7 @@ import { FILE_BYTES, type AttachmentsHarness, } from './fixtures/attachments-authz-harness.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -116,11 +117,8 @@ describe('sys_attachment delete gate under an unscoped multi-delete (#9483)', () // The delete BIT first. Without it RBAC refuses ahead of the attachment // hook with PERMISSION_DENIED, and every assertion below would be green for // the wrong reason — a denial from a layer this file is not about. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, @@ -311,11 +309,8 @@ describe('sys_attachment unscoped multi-delete is refused on its SHAPE, not on e // Same RBAC premise as the block above: without the delete bit the refusals // below would come from RBAC, about a different question entirely. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 4cbacd32505..ece97fc5786 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -45,6 +45,7 @@ import { defineStack, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ const CAP = 'view_all_audit_log'; @@ -178,8 +179,8 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren const userIdOf = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, context: { ...SYS } }))?.id ?? ''); const grant = async (email: string, setName: string) => { - const set = await ql.findOne('sys_permission_set', { where: { name: setName }, context: { ...SYS } }); - expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, setName); + expect(set, `fixture permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await userIdOf(email), permission_set: set.name }, { context: { ...SYS } }); }; for (const who of ['holder', 'reader', 'writer'] as const) { @@ -406,8 +407,8 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga expect((await stack.apiAs(tok[who], 'POST', '/auth/organization/set-active', { organizationSlug: `alc-org-${who}` })).status).toBe(200); } const userA = await ql.findOne('sys_user', { where: { email: 'alc-org-a@verify.test' }, context: { ...SYS } }); - const set = await ql.findOne('sys_permission_set', { where: { name: capOnly.name }, context: { ...SYS } }); - await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: set.name }, { context: { ...SYS } }); + expect(await catalogPermissionSet(ql, capOnly.name), 'fixture permission set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: capOnly.name }, { context: { ...SYS } }); for (const who of ['a', 'b'] as const) { const res = await stack.apiAs(tok[who], 'POST', `/data/${OBJ}`, { name: `gone ${who}` }); expect(res.status).toBeLessThan(300); diff --git a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts index 159cf83fcd6..c01ed260ce8 100644 --- a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts @@ -62,6 +62,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'alv_item'; const LEDGER = 'sys_audit_log'; @@ -188,8 +189,7 @@ describe('[#21155] the compliance ledger serves a parent field value only to a r const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts index c9ac967acd0..6baff4bef46 100644 --- a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts @@ -38,6 +38,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LEDGER = 'sys_audit_log'; @@ -81,8 +82,8 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i ql = await stack.kernel.getServiceAsync('objectql'); const readerId = (await ql.findOne('sys_user', { where: { email: READER_EMAIL }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: ledgerReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, ledgerReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: readerId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index ea5c7c0de20..05582b0aa81 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -76,6 +76,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the two objects under probe ──────────────────────────────────────────── @@ -229,8 +230,8 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller expect(aliceId).toBeTruthy(); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, { context: { ...SYS } }); }; diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index b7c207aeec0..07075c06151 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -406,9 +406,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' }, { id: 'declarative-rbac-seeding', summary: 'stack-declared positions served by the security catalog (no row) + sharingRules seeded at boot', state: 'enforced', enforcement: 'core createSecurityCatalogReader (positions from the registry, ADR-0131 D2) + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, - { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets seeded into sys_permission_set with package provenance (packageId + managed_by)', state: 'enforced', - enforcement: 'plugin-security bootstrapDeclaredPermissions — ADR-0086 D5 (managed_by:package re-seeded on boot/upgrade; env-authored platform/user/legacy rows never clobbered); provenance fields ADR-0086 D3 (spec PermissionSetSchema.packageId/managedBy + sys_permission_set.package_id/managed_by)', proof: 'showcase-permission-seeding.dogfood.test.ts', - note: 'Closes the ADR-0078 inert-metadata violation for stack.permissions — declared sets were runtime-enforced via the registry but never materialized as records (invisible to the admin surface, uninstall undefined). This row pins the seeding so it cannot silently regress to inert.' }, + { id: 'declarative-permission-seeding', summary: 'stack-declared permission sets served by the security catalog with their owning package (no row)', state: 'enforced', + enforcement: 'core createSecurityCatalogReader (permission sets from the registry, `_packageId` the owner, ADR-0131 D3); the boot writes no sys_permission_set row (C3 stage 6b-2 retired ADR-0086 D5\'s seeding)', proof: 'showcase-permission-seeding.dogfood.test.ts', + note: 'Was the ADR-0086 D5 seeding pin — declared sets materialized as managed_by:package records. ADR-0131 D3 gives the catalog one home, the registry, and the proof now pins that a declared set is served from it with its package while no row is written.' }, { id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_position, decoupled from better-auth membership)', state: 'enforced', enforcement: 'runtime/resolve-execution-context.ts reads sys_user_position (union sys_member.role) — ADR-0057 D4' }, diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 3ea631d7122..2f6053e5831 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -58,6 +58,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the app under probe ──────────────────────────────────────────────────── @@ -188,10 +189,8 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => expect(bobId).toBeTruthy(); expect(aliceId).toBeTruthy(); - const setRow = await ql.findOne('sys_permission_set', { - where: { name: 'probe_widener' }, context: { ...SYS }, - }); - expect(setRow?.id, 'the app-declared widener set is seeded').toBeTruthy(); + const setRow = await catalogPermissionSet(ql, 'probe_widener'); + expect(setRow, 'the app-declared widener set is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: bobId, permission_set: setRow.name }, { context: { ...SYS } }); diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts new file mode 100644 index 00000000000..b8a036cc26b --- /dev/null +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, C3 stage 6b-2] Does the security catalog serve this permission + * set — the fixture precondition that replaced "is its `sys_permission_set` + * row seeded". + * + * No boot writes a `sys_permission_set` row any more: a set is served by name + * from the registry, and a grant names its set. So a fixture that grants a set + * asks the catalog the security plugin bound to its engine — the read the + * authorization resolver itself uses — and gets the definition back, or + * `undefined` when no reader holds the name. + */ + +import { securityCatalogReaderOf } from '@objectstack/core'; + +export async function catalogPermissionSet( + ql: unknown, + name: string, +): Promise> | undefined> { + const reader = securityCatalogReaderOf(ql); + if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); + return (await reader.resolve('permission', name))?.definition; +} + +/** + * The `sys_permission_set` row an EARLIER release's boot wrote for a declared + * set, written here by a fixture that models an EXISTING database (the data + * door's edit, reset and lock paths still run against such rows until the + * object retires). The body is the catalog definition's, in the column shape + * the retired seeders wrote; `managedBy` / `packageId` are the provenance they + * stamped (`'package'` with the owning package, `'platform'` for a platform + * default). Idempotent: an existing organization-less row is returned as is. + */ +export async function ensureLegacyPermissionSetRow( + ql: any, + name: string, + provenance: { managedBy: 'package' | 'platform' }, +): Promise { + const SYS = { isSystem: true }; + const existing = ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own) return own; + const def: any = await catalogPermissionSet(ql, name); + if (!def) throw new Error(`the security catalog serves no permission set '${name}'`); + await ql.insert('sys_permission_set', { + id: `ps_legacy_${name}`, + name, + label: def.label ?? name, + description: def.description ?? null, + object_permissions: JSON.stringify(def.objects ?? {}), + field_permissions: JSON.stringify(def.fields ?? {}), + system_permissions: JSON.stringify(def.systemPermissions ?? []), + row_level_security: JSON.stringify(def.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(def.tabPermissions ?? {}), + admin_scope: def.adminScope ? JSON.stringify(def.adminScope) : null, + active: true, + managed_by: provenance.managedBy, + ...(provenance.managedBy === 'package' && typeof def._packageId === 'string' ? { package_id: def._packageId } : {}), + }, { context: SYS }); + return ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) + .find((r: any) => !r.organization_id); +} diff --git a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts index aa8beceffe8..7c4adcbda1e 100644 --- a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts @@ -68,6 +68,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -257,8 +258,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do memberId = await uid('cpe-member@verify.test'); stewardTok = await stack.signUp('cpe-steward@verify.test'); stewardId = await uid('cpe-steward@verify.test'); - const stewardSet = await ql.findOne('sys_permission_set', { where: { name: contractStewardSet.name }, context: SYS }); - expect(stewardSet?.id, 'fixture permission set seeded').toBeTruthy(); + const stewardSet = await catalogPermissionSet(ql, contractStewardSet.name); + expect(stewardSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: stewardId, permission_set: stewardSet.name }, { context: { ...SYS } }); await assertArmed([ @@ -472,8 +473,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do beforeAll(async () => { boardStewardTok = await stack.signUp('cpe-board-steward@verify.test'); const boardStewardId = await uid('cpe-board-steward@verify.test'); - const set = await ql.findOne('sys_permission_set', { where: { name: boardStewardSet.name }, context: SYS }); - expect(set?.id, 'board steward permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, boardStewardSet.name); + expect(set, 'board steward permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: boardStewardId, permission_set: set.name }, { context: { ...SYS } }); await assertArmed([ principalArmed({ diff --git a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts index 7c4d31b936c..eb928317827 100644 --- a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts @@ -41,6 +41,7 @@ import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { cpgStack, cpgSecurity } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -161,8 +162,8 @@ describe('controlled_by_parent parent gates: sys_attachment and sys_comment judg adminId = await uid('admin@objectos.ai'); memberId = await uid('cpg-member@verify.test'); - const domainSet = await ql.findOne('sys_permission_set', { where: { name: 'cpg_files_and_threads' }, context: SYS }); - expect(domainSet?.id, 'fixture permission set seeded').toBeTruthy(); + const domainSet = await catalogPermissionSet(ql, 'cpg_files_and_threads'); + expect(domainSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: domainSet.name }, { context: { ...SYS } }); await assertArmed([ diff --git a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts index 090ad97fd1a..640ef281203 100644 --- a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts @@ -70,6 +70,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { commentsFixtureStack, commentsFixtureSecurity } from './fixtures/comments-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('sys_comment permission matrix (#4630)', () => { // Domain grant (see commentManagerSet): both members may DELETE comments; // the everyone-anchor baseline carries no delete bit. - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'cmt_comment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'cmt_comment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts index 66707bf01af..7a82bca17da 100644 --- a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts @@ -57,6 +57,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -205,8 +206,8 @@ describe('security/explain answers every write verb as its door does, on read-ab for (const { axis, set, email } of AXES) { const token = await stack.signUp(email); const userId = await uid(email); - const row = await ql.findOne('sys_permission_set', { where: { name: set.name }, context: SYS }); - expect(row?.id, `${set.name} seeded`).toBeTruthy(); + const row = await catalogPermissionSet(ql, set.name); + expect(row, `${set.name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: row.name }, { context: { ...SYS } }); tokens[axis] = token; } diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index 1232173b95b..d436351cdb0 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -47,6 +47,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SysUser } from '@objectstack/platform-objects/identity'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DOMAIN = 'org-peer-21237.verify.test'; @@ -149,8 +150,8 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a await memberOf(uid[who], role); } - const [activitySet] = await findRows(ql, 'sys_permission_set', { name: activityReadSet.name }, 1); - expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + const activitySet = await catalogPermissionSet(ql, activityReadSet.name); + expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const who of ['member', 'owner', 'admin']) { await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); } diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index 425c8db9a82..cf471402372 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -54,6 +54,7 @@ import { resolveAuthzContext } from '@objectstack/core'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** * [#8074] The control every case below measures, and the default that silences @@ -226,8 +227,8 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el const carolId = await uid('owdw-carol@verify.test'); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 3b76301ed6c..d78bc2a21d7 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -33,6 +33,7 @@ import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = '/data/showcase_private_note'; const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; @@ -97,8 +98,8 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => // Direct per-user grant of the delete set (never anchor-bound). const SYS = { isSystem: true } as const; - const delSet = await ql.findOne('sys_permission_set', { where: { name: 'anchor_note_delete' }, context: SYS }); - expect(delSet?.id, 'declared delete set seeded').toBeTruthy(); + const delSet = await catalogPermissionSet(ql, 'anchor_note_delete'); + expect(delSet, 'declared delete set served by the security catalog').toBeTruthy(); for (const userId of [aliceId, bobId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: delSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index 43c4afcd889..979c594a2f7 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -58,6 +58,7 @@ import { AuditPlugin } from '@objectstack/plugin-audit'; import { buildAttSecret, buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet } from './fixtures/attachments-fixture.js'; import { buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -131,8 +132,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts index ff3e15c37fe..b017b6e7166 100644 --- a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const GRANTS = 'sys_user_permission_set'; @@ -80,8 +81,8 @@ describe('a permission-set assignment grants only inside its validity window (AD adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); - expect(set?.id, `the showcase seeded ${SET}`).toBeTruthy(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the showcase served by the security catalog ${SET}`).toBeTruthy(); for (const who of ['bare', 'open', 'before', 'inside', 'after', 'edited', 'falls', 'rises']) await sign(who); const now = Date.now(); diff --git a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts deleted file mode 100644 index 5a897849fe8..00000000000 --- a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21669] A permission set the environment CLONED is not reported, on the next -// boot, as a package declaration with no owner — over the real showcase -// composition, across a cold boot on one database file. -// -// ## What was broken -// -// The declared-permission boot loop (`bootstrapDeclaredPermissions`, -// plugin-security) walks every `permission` item in the engine registry. That -// registry holds more than package declarations: `loadMetaFromDb` hydrates every -// env-wide `sys_metadata` `permission` row into the same collection, and a set -// made with Setup's Clone action is one — with no package id, because it has -// none. The loop judged "unowned" before it looked at the row, so every boot -// (and every `metadata:reloaded`) logged -// `[permission_set_declaration_unowned] … the Setup admin surface … cannot see -// this set` for a set whose `managed_by:'admin'` row Setup lists and edits. -// Measured on `origin/main` before the fix through this file's own steps: one -// such line naming the clone, `skippedUnowned: 1`. -// -// ## Why a booted stack, booted twice -// -// The unit pins (`bootstrap-declared-permissions.test.ts`, `[#21669]`) hand the -// loop the item shape this boot produces. What they cannot show is that the -// real boot puts the clone into the walk at all — that is a property of the -// hydration, not of the loop, and only a restart on the same file shows it. So -// this file asserts the walk holds the clone with no package id BEFORE it -// asserts the warning is absent; without that precondition the absence would be -// vacuous. -// -// ## The clone is made the way the Setup dialog makes it -// -// The payload is built from the shipped `clone_permission_set` action's own -// declaration — its `target`, its `bodyExtra` and its `params` (the typed -// `label`/`name`, and every `defaultFromRow` facet copied from the base row) — -// and POSTed to the data door as the signed-in admin. Read, never restated: a -// facet added to the action reaches this pin without an edit here. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { securityObjects } from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const TOKEN = 'permission_set_declaration_unowned'; -/** A set the showcase package ships (`com.example.showcase`) — the clone's base. */ -const BASE = 'showcase_contributor'; -/** A name no package ships. */ -const CLONE = 'showcase_contributor_local_21669'; -const API_BASE = '/api/v1'; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ -async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { - const lines: string[] = []; - const stdout = process.stdout.write.bind(process.stdout); - const stderr = process.stderr.write.bind(process.stderr); - const warn = console.warn; - (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; - (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; - console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; - try { - return { value: await run(), lines }; - } finally { - (process.stdout as any).write = stdout; - (process.stderr as any).write = stderr; - console.warn = warn; - } -} - -describe('[#21669] a cloned permission set boots without the unowned-declaration warning (showcase, cold boot)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - /** Everything boot 2 wrote. */ - let bootLines: string[] = []; - /** Boot 2's engine. */ - let ql: any; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21669-')); - dbFile = join(dir, 'showcase.db'); - - // ── boot 1: clone a packaged set through the Setup dialog's own path ── - stack = await bootShowcase({ databaseFile: dbFile }); - const token = await stack.signIn(); - const engine: any = await stack.kernel.getServiceAsync('objectql'); - const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS); - expect(base?.managed_by, 'the base is a package-declared set').toBe('package'); - - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (local)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const created = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Harness health: the clone is the environment's own set — the row - // Setup lists, and the env-wide metadata row the next boot hydrates. - const [clone] = await engine.find('sys_permission_set', { where: { name: CLONE }, limit: 1 }, SYS); - expect({ managed_by: clone?.managed_by, package_id: clone?.package_id ?? null }) - .toEqual({ managed_by: 'admin', package_id: null }); - const stored = await engine.find('sys_metadata', { where: { type: 'permission', name: CLONE } }, SYS); - expect(stored.map((r: any) => ({ state: r.state, organization_id: r.organization_id ?? null }))) - .toEqual([{ state: 'active', organization_id: null }]); - await stack.stop(); - stack = undefined; - - // ── boot 2: same file, nothing authored ─────────────────────────────── - const second = await captureOutput(() => bootShowcase({ databaseFile: dbFile })); - stack = second.value; - bootLines = second.lines; - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the boot loop walks the clone, with no package id', () => { - // ⛔ Without this the absence below proves nothing: a boot that stopped - // hydrating the clone would also log no warning for it. - const item = (ql.registry.listItems('permission') ?? []).find((i: any) => i?.name === CLONE); - expect(item, `${CLONE} is in the registry the loop walks`).toBeDefined(); - expect(item._packageId ?? item.packageId ?? null).toBeNull(); - }); - - it('control: the capture saw the loop report its pass', () => { - // The seeding pass logs one summary line per run, through the same - // logger the warning used — so an empty capture cannot pass the next case. - expect(bootLines.some((l) => l.includes('declared permission sets seeded into sys_permission_set'))).toBe(true); - }); - - it('the cloned set is not reported as an unowned declaration', () => { - const unowned = bootLines.filter((l) => l.includes(TOKEN) && l.includes(CLONE)); - expect(unowned).toEqual([]); - }); -}); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts index 72ccea5f54a..fd675b10c3c 100644 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts @@ -62,6 +62,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -163,7 +164,11 @@ describe('[#21789] the permission-set lock reads the row\'s provenance — the t expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. + // is built from the shipped action's own declaration. [C3 stage 6b-2] + // On an EXISTING database: no boot writes the shipped set's row any + // more, but an earlier one did (the server clone door, stage CD, is + // what reaches a fresh database). + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); const served: any = await read.json(); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts index f0dba0ef8cc..10b54cd131f 100644 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts @@ -45,6 +45,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -122,6 +123,9 @@ describe('[#21861] a data-door edit of a permission set updates its own sys_meta stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); + // [C3 stage 6b-2] The control runs on an EXISTING database: no boot + // writes the shipped set's row any more, but an earlier one did. + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); // A writable runtime package, and a set saved into it at the metadata door. const pkg = await stack.apiAs(token, 'POST', '/packages', { diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 9a94491654c..f057cadfa14 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -57,6 +57,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -154,8 +155,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts index f881b656746..032002a3609 100644 --- a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts @@ -37,6 +37,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PinyinSearchPlugin } from '@objectstack/plugin-pinyin-search'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** `翟璐` normalizes to `zhailu zl` — the term below lives in the companion only. */ const CJK_NAME = '翟璐'; @@ -171,9 +172,8 @@ describe('dogfood: with the pinyin search companion on, a member searches only w const idOf = async (object: string, where: Record) => String((await ql.findOne(object, { where, ...SYS }))?.id ?? ''); const userId = await idOf('sys_user', { email: memberEmail }); - const setId = await idOf('sys_permission_set', { name: memberSet.name }); expect(userId, 'member user seeded').toBeTruthy(); - expect(setId, 'member permission set seeded').toBeTruthy(); + expect(await catalogPermissionSet(ql, memberSet.name), 'member permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: memberSet.name }, SYS); memberRowId = await create(memberToken, ROWS, { name: CJK_NAME }); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e184e421ee0..a3b0a6f97b8 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -64,6 +64,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { leaveOrganization } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -186,8 +187,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // A platform operator outside the organization: `admin_full_access` held // globally, the grant a platform administrator holds under `single`. await stack.signUp(OPERATOR_EMAIL, PASSWORD); - const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); - expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); + expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts index ce98ead44a0..3dd5a007252 100644 --- a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -48,8 +49,8 @@ describe('showcase: ADR-0090 D10 agent intersection (served engine)', () => { // The agent holds the auditor set (viewAllRecords on the private note); the // delegator holds NOTHING beyond the additive member_default baseline. - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); - expect(auditor?.id, 'showcase_auditor seeded').toBeTruthy(); + const auditor = await catalogPermissionSet(ql, 'showcase_auditor'); + expect(auditor, 'showcase_auditor served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: agentId, permission_set: auditor.name }, { context: SYS }); // A private note owned by int-owner (NOT the delegator) — the VAMA probe. diff --git a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts index 94390f593c3..3c7d22063ea 100644 --- a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LIAISON_SET = 'showcase_client_liaison'; @@ -78,8 +79,8 @@ describe('showcase client-liaison fixtures (#9308 fixtures 2 + 4)', () => { ql = await stack.kernel.getServiceAsync('objectql'); liaisonTok = await stack.signUp(LIAISON_EMAIL); - const set = await ql.findOne('sys_permission_set', { where: { name: LIAISON_SET }, context: SYS }); - expect(set?.id, `${LIAISON_SET} is seeded by the security bootstrap`).toBeTruthy(); + const set = await catalogPermissionSet(ql, LIAISON_SET); + expect(set, `${LIAISON_SET} is served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email: LIAISON_EMAIL }, context: SYS }))?.id; await ql.insert('sys_user_permission_set', { user_id: uid, permission_set: set.name }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 01b6bcf89ac..821d7b294d2 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -67,6 +67,7 @@ import { fileURLToPath } from 'node:url'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -343,8 +344,8 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const email = `crudmx-${set.replace(/^showcase_/, '')}@verify.test`; const token = await stack.signUp(email); if (set !== BASELINE_SET) { - const ps = await ql.findOne('sys_permission_set', { where: { name: set }, context: SYS }); - expect(ps?.id, `permission set ${set} seeded by the security bootstrap`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, set); + expect(ps, `permission set ${set} served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; expect(uid, `persona ${email} provisioned`).toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts index c0ca3dec226..b8f474c5640 100644 --- a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -75,8 +76,8 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# const contribId = (await ql.findOne('sys_user', { where: { email: CONTRIB_EMAIL }, context: SYS }))?.id; expect(contribId, 'contributor user provisioned').toBeTruthy(); await ql.insert('sys_user_position', { user_id: contribId, position: 'contributor' }, { context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(contribSet?.id, 'the app’s contributor set is seeded').toBeTruthy(); + const contribSet = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contribSet, 'the app’s contributor set is served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: contribId, permission_set: contribSet.name }, diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index c23377e797c..e0a67c590c4 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -74,6 +74,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked active: true, }); expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); - const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); - expect(ps?.id, 'scratch permission set authored').toBeTruthy(); + const ps = await catalogPermissionSet(ql, SCRATCH_SET); + expect(ps, 'scratch permission set authored').toBeTruthy(); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; expect(memberId, 'member provisioned').toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index f80e7e1a79f..5c31472454f 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -30,6 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const SET = 'showcase_contributor'; @@ -96,8 +97,8 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); - expect(set?.id, `the app's ${SET} set is seeded by the security bootstrap`).toBeTruthy(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the app's ${SET} set is served by the security catalog`).toBeTruthy(); // [ADR-0131 D2/D3] The position → set edge is the app's own declaration — // `contributor` names `showcase_contributor` in its `permissionSets` — and // the resolver reads it there. No `sys_position` row and no junction row diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts index 0c88b8c2dbc..53f32344ae8 100644 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -33,6 +33,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('sys_permission_set pure projection (ADR-0094)', () => { let stack: VerifyStack; @@ -45,6 +46,11 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + // [C3 stage 6b-2] The declared-set cases run on an EXISTING database: + // no boot writes these rows any more, but an earlier one did, and the + // data door still guards them. + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); afterAll(async () => { await stack?.stop(); }); diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 251657ca92e..0df4427537c 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -1,12 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// ADR-0086 D5 — stack-declared `permissions` are seeded into -// `sys_permission_set` at boot with package provenance -// (`managed_by:'package'` + `package_id`), closing the ADR-0078 -// inert-metadata violation for this surface: the admin table finally sees a -// package's sets, and uninstall/upgrade have a well-defined owner axis. -// Proven on the real showcase stack, which declares `showcase_contributor` -// and `showcase_member_default` in `src/security/`. +// ADR-0131 D3 (C3 stage 6b-2) — stack-declared `permissions` are served by the +// security catalog from the registry, with their owning package, and the boot +// writes NO `sys_permission_set` row: not for a package's sets, not for the +// platform defaults. (ADR-0086 D5's boot seeding, which this file used to pin, +// retired with the declared-permission seeder.) Proven on the real showcase +// stack, which declares `showcase_contributor` and `showcase_member_default` +// in `src/security/`. // // ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof // for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). @@ -15,8 +15,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; -describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { +describe('showcase: declared permission sets are served by the security catalog (ADR-0131 D3)', () => { let stack: VerifyStack; let ql: any; @@ -27,27 +28,26 @@ describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { }, 60_000); afterAll(async () => { await stack?.stop(); }); - it('declared sets land in sys_permission_set with package provenance', async () => { - const rows = await ql.find('sys_permission_set', { where: {} }, { context: { isSystem: true } }); - const contributor = (rows ?? []).find((r: any) => r.name === 'showcase_contributor'); - expect(contributor, 'declared set must be materialized as a record').toBeTruthy(); - expect(contributor.managed_by).toBe('package'); - expect(contributor.package_id).toBe('com.example.showcase'); - // the record carries the actual declared grants, not an empty husk - const objectPerms = JSON.parse(contributor.object_permissions || '{}'); - expect(Object.keys(objectPerms).length).toBeGreaterThan(0); + it('a declared set is served with its owning package and its declared grants', async () => { + const contributor: any = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contributor, 'declared set served by the security catalog').toBeTruthy(); + expect(contributor._packageId).toBe('com.example.showcase'); + // the definition carries the actual declared grants, not an empty husk + expect(Object.keys(contributor.objects ?? {}).length).toBeGreaterThan(0); }); - it('platform defaults stay env-owned (no package provenance stamped)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'member_default' } }, { context: { isSystem: true } }); - const memberDefault = (rows ?? [])[0]; - expect(memberDefault, 'bootstrapPlatformAdmin default exists').toBeTruthy(); - // bootstrapDeclaredPermissions must not adopt/clobber the insert-once default - expect(memberDefault.managed_by ?? null).not.toBe('package'); + it('a platform default is served too, with no package of the app stamped on it', async () => { + const memberDefault: any = await catalogPermissionSet(ql, 'member_default'); + expect(memberDefault, 'platform default served by the security catalog').toBeTruthy(); + expect(memberDefault._packageId ?? null).not.toBe('com.example.showcase'); }); - it('seeding is idempotent (exactly one row per declared set)', async () => { - const rows = await ql.find('sys_permission_set', { where: { name: 'showcase_contributor' } }, { context: { isSystem: true } }); - expect((rows ?? []).length).toBe(1); + it('the boot writes no sys_permission_set row — and the census can see one', async () => { + const SYS = { context: { isSystem: true } }; + expect(await ql.find('sys_permission_set', { where: {} }, SYS)).toEqual([]); + // Positive control: the same read sees a row once one exists. + await ql.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, SYS); + expect(((await ql.find('sys_permission_set', { where: {} }, SYS)) ?? []).map((r: any) => r.name)).toEqual(['zz_census_control']); + await ql.delete('sys_permission_set', { where: { id: 'ps_census_control' }, ...SYS }); }); }); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index b7268aebcbf..69a5627a97e 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet, ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -46,8 +47,8 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // Grant the zoo sets directly (system plumbing — the delegated-admin and // anchor gates are exercised through REAL authed requests below). const grant = async (userId: string, setName: string) => { - const ps = await ql.findOne('sys_permission_set', { where: { name: setName }, context: SYS }); - expect(ps?.id, `permission set ${setName} seeded`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, setName); + expect(ps, `permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: ps.name }, @@ -86,10 +87,12 @@ describe('showcase: ADR-0090 permission-model zoo', () => { }); // ── ADR-0090 D12: adminScope lands on the stored set ───────────────────── - it('persists the delegated-admin scope (adminScope → admin_scope JSON)', async () => { - const row = await ql.findOne('sys_permission_set', { where: { name: 'showcase_field_ops_delegate' }, context: SYS }); - expect(row, 'delegate set seeded').toBeTruthy(); - const scope = JSON.parse(row.admin_scope || 'null'); + it('serves the delegated-admin scope on the catalog definition (adminScope)', async () => { + // [C3 stage 6b-2] No row is written for a declared set; the catalog + // definition is what the delegated-admin gate reads. + const def: any = await catalogPermissionSet(ql, 'showcase_field_ops_delegate'); + expect(def, 'delegate set served by the security catalog').toBeTruthy(); + const scope = def.adminScope ?? null; expect(scope?.businessUnit).toBe('Field Operations'); expect(scope?.manageAssignments).toBe(true); expect(scope?.manageBindings).toBe(false); @@ -141,13 +144,17 @@ describe('showcase: ADR-0090 permission-model zoo', () => { await ql.insert('sys_position', { id: `pos_zoo_${name}`, name, label: name, managed_by: managedBy }, { context: SYS }); return ql.findOne('sys_position', { where: { name }, context: SYS }); }; + // [C3 stage 6b-2] The same for a permission-set row: no boot writes one any + // more, while the junction writes below (which grant nothing since stage 1) + // still name a row by id — the shape an existing database keeps. + const existingSetRow = (name: string) => ensureLegacyPermissionSetRow(ql, name, { managedBy: 'package' }); // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { // Bind contributor → showcase_contributor first (admin action; the // delegate has manageBindings: false and must NOT be able to do this). const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); + const ps = await existingSetRow('showcase_contributor'); expect(pos?.id && ps?.id, 'contributor position + set rows').toBeTruthy(); const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, @@ -180,17 +187,16 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('the delegate cannot hand out a set OFF the allowlist (no self-escalation)', async () => { // showcase_auditor is not in assignablePermissionSets — direct grant refused. - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_permission_set', { user_id: delegateId, - permission_set: ps.name, + permission_set: 'showcase_auditor', }); expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); }); it('the delegate cannot re-compose positions (manageBindings: false)', async () => { const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); + const ps = await existingSetRow('showcase_manager'); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, permission_set_id: ps.id, @@ -202,7 +208,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { const everyone = await existingPositionRow('everyone', 'platform'); expect(everyone, 'built-in everyone position row').toBeTruthy(); - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); + const auditor = await existingSetRow('showcase_auditor'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: everyone.id, permission_set_id: auditor.id, @@ -213,7 +219,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding the guest-safe set to the guest anchor is accepted', async () => { const guest = await existingPositionRow('guest', 'platform'); expect(guest, 'built-in guest position row').toBeTruthy(); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); + const ps = await existingSetRow('showcase_guest_portal'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: guest.id, permission_set_id: ps.id, @@ -229,8 +235,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // that; this proves the served pipeline actually denies the write now. it('FLS: a contributor can edit a project but NOT its budget fields', async () => { const plain = await ql.findOne('sys_user', { where: { email: 'zoo-plain@verify.test' }, context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: contribSet.name }, { context: SYS }) + await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: 'showcase_contributor' }, { context: SYS }) .catch(() => { /* already granted by an earlier step — idempotent */ }); const projects = await ql.find('showcase_project', { limit: 1, context: SYS }); diff --git a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts index a7178dcdd40..1b680ca3706 100644 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('two-doors permission separation (ADR-0086 P2)', () => { let stack: VerifyStack; @@ -38,45 +39,18 @@ describe('two-doors permission separation (ADR-0086 P2)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); const findSet = async (name: string) => (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - // ── 块1 — package door: draft → publish → materialize ──────────────────── - it('块1: publishing a package permission draft materializes a package-managed row', async () => { - // An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage), - // standing in for the package the studio package door edits. - const PKG = 'com.example.twodoors_ws'; - const NAME = 'twodoors_pkgset'; - - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - mode: 'draft', - packageId: PKG, - item: { - name: NAME, - label: 'Two Doors Set', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - }, - }); - - // Draft only — enforcement/admin-surface must NOT see it yet. - expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy(); - - const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME }); - expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy(); - expect(pub.materializeApplied.success).toBe(true); - - const row = await findSet(NAME); - expect(row, 'published set is now a real record').toBeTruthy(); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe(PKG); - expect(JSON.parse(row.object_permissions || '{}')).toEqual({ - crm_lead: { allowRead: true, allowCreate: true }, - }); - }); + // [C3 stage 6b-2] 块1 — a package-door publish materializing a + // `managed_by:'package'` row — retired with the publish materializer + // (ADR-0131 D3: the boot and the publish write no catalog row). The 块2 + // cases below run on an EXISTING database, where the rows an earlier boot + // seeded still stand and the data door still guards them. // ── 块2 — admin door: artifact-backed rows now REFUSE the overlay (commit ee58392e1) ─ it('块2: an admin edit of a package-managed set is refused — no env overlay is minted (#6483)', async () => { diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index ec482ead452..c109eb5919a 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -52,6 +52,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; @@ -123,8 +124,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); From 6c849cef3938661c2a9a83841dee7ef3bb9f1d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:25:59 +0000 Subject: [PATCH 13/39] test(dogfood): catalogPermissionSet answers any, as the findOne it replaces did (#15204 stage 6b-2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- packages/qa/dogfood/test/catalog-permission-set.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts index b8a036cc26b..fd48332bbe4 100644 --- a/packages/qa/dogfood/test/catalog-permission-set.ts +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -14,10 +14,11 @@ import { securityCatalogReaderOf } from '@objectstack/core'; -export async function catalogPermissionSet( - ql: unknown, - name: string, -): Promise> | undefined> { +/** + * Typed `any`, as the `ql.findOne` it replaces at every call site was: a fixture + * asserts the definition exists and then reads its fields. + */ +export async function catalogPermissionSet(ql: unknown, name: string): Promise { const reader = securityCatalogReaderOf(ql); if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); return (await reader.resolve('permission', name))?.definition; From 8b9fb7781e40b0d4f87f2fed54b1a0ad1ec318f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:40:13 +0000 Subject: [PATCH 14/39] chore(docs-audit): the CLI command-anchor specimen is os meta register, a command that exists (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- scripts/check-cli-command-ids.mjs | 2 +- scripts/docs-audit/affected-docs.mjs | 32 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/scripts/check-cli-command-ids.mjs b/scripts/check-cli-command-ids.mjs index a7db333dfc6..1a0c4fe1b8f 100644 --- a/scripts/check-cli-command-ids.mjs +++ b/scripts/check-cli-command-ids.mjs @@ -317,7 +317,7 @@ const ROOT_DIR_WATCH_HINTS = ['scripts/**']; const FIXTURE_EXEMPTIONS = [ { file: 'scripts/docs-audit/affected-docs.mjs', - text: 'os meta resync-plan', + text: 'os meta register-plan', why: "affected-docs's own self-test case for 'a sibling command id is not this one'", }, { diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 6010e3c025e..2a549dcc8e3 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -5008,18 +5008,18 @@ function selfTest() { const commandIdCases = [ // [path under the commands root, expected id, label] - ['meta/resync.ts', 'meta resync', 'topic + command — the #9230 specimen'], + ['meta/register.ts', 'meta register', 'topic + command (the #9230 specimen was `meta resync`, since retired)'], ['build.ts', 'build', 'a top-level command'], ['migrate/recorded-by.ts', 'migrate recorded-by', 'a hyphen is an ordinary id char'], ['migrate/index.ts', 'migrate', 'a topic index IS the topic, not "migrate index"'], ['a/b/c.ts', 'a b c', 'deeper nesting is read off the path, not special-cased'], ['cloud/whoami.js', 'cloud whoami', 'the compiled extension resolves the same way'], - ['meta/resync-skip-explanation.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], + ['meta/delete-json-error-code.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], ['__tests__/helper.ts', null, 'an underscore dir is not an oclif id segment'], ['meta/fixture.json', null, 'a non-source file under the commands dir'], ['index.ts', null, 'a bare index at the commands root would name the binary, not a command'], - ['meta/Resync.ts', null, 'an uppercase filename is not an oclif id'], + ['meta/Register.ts', null, 'an uppercase filename is not an oclif id'], ]; for (const [rel, want, label] of commandIdCases) check('commandIdFor', label, rel, want, commandIdFor(rel)); @@ -5043,7 +5043,7 @@ function selfTest() { const readFakePkg = (dir) => (dir === 'packages/cli' ? cliPkg : { name: '@objectstack/spec' }); const anchorFor = (f) => commandAnchorFor(f, readFakePkg, (d) => cliTree.has(d)); const commandAnchorCases = [ - ['packages/cli/src/commands/meta/resync.ts', 'os meta resync', 'the specimen resolves to its phrase'], + ['packages/cli/src/commands/meta/register.ts', 'os meta register', 'the specimen resolves to its phrase'], ['packages/cli/src/commands/build.ts', 'os build', 'a top-level command'], ['packages/cli/src/utils/schema-migrate.js', null, 'a CLI file OUTSIDE the commands dir is not a command'], ['packages/spec/src/commands/thing.ts', null, 'a commands dir in a package that declares no oclif is ignored'], @@ -5057,20 +5057,20 @@ function selfTest() { check('commandAnchorFor', 'a file outside any commands dir is not reported as unmapped either', 'packages/cli/src/utils/schema-migrate.js', null, anchorFor('packages/cli/src/utils/schema-migrate.js')); - // The doc-side matcher. `os meta resync` must find the page that writes it and must NOT + // The doc-side matcher. `os meta register` must find the page that writes it and must NOT // find prose that merely uses the words. - const cmdRe = commandPatternFor('meta resync', ['os', 'objectstack']); + const cmdRe = commandPatternFor('meta register', ['os', 'objectstack']); const commandMatchCases = [ - ['`os migrate`, `os meta resync`, a test run.', true, 'the real drivers.mdx line — inside a code span'], - ['Run `objectstack meta resync --json` to re-sync.', true, 'the alternate declared binary'], - ['npx objectstack meta resync', true, 'the npx form is the same phrase with a prefix'], - ['os meta\tresync', true, 'extra horizontal whitespace between the words'], - ['resync the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], - ['the meta resync step', false, 'the words without a binary name are prose'], - ['macos meta resync', false, 'a binary name must stand alone, not end another word'], - ['os meta resyncAll', false, 'a longer identifier is not the command'], - ['os meta resync-plan', false, 'a sibling command id is not this one'], - ['os meta\nresync', false, 'a line break is not intra-command whitespace'], + ['`os migrate`, `os meta register`, a test run.', true, 'inside a code span'], + ['Run `objectstack meta register --json` to register it.', true, 'the alternate declared binary'], + ['npx objectstack meta register', true, 'the npx form is the same phrase with a prefix'], + ['os meta\tregister', true, 'extra horizontal whitespace between the words'], + ['register the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], + ['the meta register step', false, 'the words without a binary name are prose'], + ['macos meta register', false, 'a binary name must stand alone, not end another word'], + ['os meta registerAll', false, 'a longer identifier is not the command'], + ['os meta register-plan', false, 'a sibling command id is not this one'], + ['os meta\nregister', false, 'a line break is not intra-command whitespace'], ]; for (const [text, want, label] of commandMatchCases) { check('commandPatternFor', label, JSON.stringify(text), want, cmdRe.test(text)); From a574a24cafc51a88ac3bb5ea97a9045c7d0d5131 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:11:10 +0000 Subject: [PATCH 15/39] refactor(plugin-security)!: delete the organization-scope helpers the seeder was the only caller of (#15204 stage 6b-2) warnOrganizationLessRows, catalogIsPerOrganization and listSeedOrganizationIds had one caller, bootstrap-declared-permissions.ts, deleted in this PR. Re-measured: no live caller and no test. Internal (never exported from the package index). Also carries T1's buildExistingByName key-set fence byte-for-byte. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../plugin-security/src/organization-scope.ts | 159 +----------------- .../src/permission-set-projection.ts | 4 +- ...ed-name-lookup-degradation-audible.test.ts | 45 +++++ .../src/seed-write-refusal.test.ts | 2 +- .../plugin-security/src/write-refusals.ts | 4 +- 5 files changed, 57 insertions(+), 157 deletions(-) diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index 7ff44b69017..ce46e06a362 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -2,9 +2,11 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the - * system context one pass runs under, the organizations a boot sweep - * enumerates, the one read that tells an organization's own row apart from - * organization-less residue, and the loud guard that stands in place of a reap. + * system context one pass runs under and the one read that tells an + * organization's own row apart from organization-less residue. [#15204 stage + * 6b-2] The boot sweep's organization enumeration and the loud guard that stood + * in place of a reap retired with the declared-permission seeder, their one + * caller; the history below is kept because the reads that remain still apply it. * * ## Why the catalog is materialized per organization * @@ -66,8 +68,8 @@ * because it never touched a junction table; here the junctions ARE the * grants. * - * So the pass says so instead. {@link warnOrganizationLessRows} names the - * rows and names the remedy, and — this is the load-bearing half — the pass + * So the pass said so instead. Its guard (retired with the seeder) named the + * rows and named the remedy, and — this is the load-bearing half — the pass * still CREATES the organization's own copy. The failure shape it exists to * prevent is the silent no-op: a tenant-threaded pass sees the pre-fix * organization-less row through the driver's compatibility arm, reads the name @@ -78,9 +80,6 @@ * here", and every seeder in this catalog routes through it. */ -import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import type { SeedLogger } from './write-refusals.js'; - /** * How many organizations one boot-time seeding sweep enumerates. * @@ -92,8 +91,6 @@ import type { SeedLogger } from './write-refusals.js'; */ export const SEED_ORGANIZATION_SCAN_LIMIT = 500; -const ORGANIZATION_OBJECT = 'sys_organization'; - /** * The system context ONE seeding pass runs under. * @@ -107,52 +104,6 @@ export function seedCtx(organizationId?: string): { isSystem: true; tenantId?: s return organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true }; } -/** Does this posture want per-organization catalog rows? */ -export function catalogIsPerOrganization(posture: TenancyPosture): boolean { - return postureEnforcesWall(posture); -} - -/** - * Enumerate the organizations whose catalog needs seeding. - * - * Returns `null` — not `[]` — when the read FAILS, because the two mean - * opposite things: zero organizations is "nothing to seed", an unreadable - * `sys_organization` is "we do not know". Conflating them turns an outage into - * a silent empty sweep, so a failure is warned and the caller must not proceed - * as though the installation had no tenants. - */ -export async function listSeedOrganizationIds( - ql: any, - logger?: SeedLogger, -): Promise { - let rows: any; - try { - rows = await ql.find(ORGANIZATION_OBJECT, { - fields: ['id'], - limit: SEED_ORGANIZATION_SCAN_LIMIT, - context: seedCtx(), - }); - } catch (e) { - logger?.warn?.( - '[security] could not enumerate organizations — the RBAC catalog was NOT seeded per ' + - 'organization at this call; seeding retries on the next boot and on organization creation', - { object: ORGANIZATION_OBJECT, error: (e as Error)?.message }, - ); - return null; - } - const ids = (Array.isArray(rows) ? rows : []) - .map((r: any) => r?.id) - .filter((id: unknown): id is string => typeof id === 'string' && id !== ''); - if (ids.length >= SEED_ORGANIZATION_SCAN_LIMIT) { - logger?.warn?.( - '[security] organization scan hit its bound — organizations past it are seeded when they are ' + - 'created and on the next boot sweep', - { scanned: ids.length, limit: SEED_ORGANIZATION_SCAN_LIMIT }, - ); - } - return ids; -} - /** The organization a stored row belongs to, `null` for an organization-less one. */ export function rowOrganizationId(row: any): string | null { return (row?.organization_id ?? row?.organizationId) ?? null; @@ -188,99 +139,3 @@ export function resolveOwnOrganizationRow( const residue = list.find((r) => rowOrganizationId(r) === null) ?? null; return { own, organizationLessResidue: residue }; } - -/** - * The machine-readable half of the guard below: WHY this organization-less row - * is visible to a per-organization pass. The two answers take opposite - * remedies, so the classification is a field rather than something a reader has - * to infer from prose (#11532). - */ -export type OrganizationLessRowOrigin = 'platform-bucket' | 'pre-fix-residue'; - -/** - * The loud guard that stands in place of a reap — and the ONE place that tells - * the platform bucket apart from a genuine pre-fix leftover. - * - * Called once per pass with everything the pass found, so an operator gets ONE - * actionable line per class rather than a warning per name. - * - * ## The two classes, and why conflating them was a defect (#11532) - * - * - **`pre-fix-residue`** — a row from before the per-organization conversion. - * Nothing regenerates it, so the ruled remedy holds: re-initialize the - * deployment (correct while it is pre-launch, which is the premise this whole - * repair was ruled on), or adopt each row by hand. - * - * - **`platform-bucket`** — a name `bootstrapPlatformAdmin` still seeds - * organization-less on EVERY boot (`platformBucketNames`). Calling one of - * these "pre-fix" tells an operator they are carrying legacy state they never - * had: on the measured fresh walled rig they were minted 1.3 s before the - * first organization existed, by the very code that then warned about them. - * And the pre-fix remedy does not terminate here — re-initializing recreates - * exactly these rows on the next boot, so the only branch that ends is hand - * adoption, which is also the branch an operator is least likely to pick and - * which hands a platform-wide bucket to one tenant. - * - * Membership is decided by NAME, not by `managed_by`, because the question the - * remedy turns on is "will a re-initialized deployment have this row again?" — - * and for these names it will, whatever provenance the current row carries (a - * pre-ruling install (before commit 712e185db) stores `'admin'` on the very same names). - * - * The pass that emits either warning has ALREADY created the organization's own - * copies — both describe rows beside that catalog, never a refusal to seed. - */ -export function warnOrganizationLessRows( - logger: SeedLogger | undefined, - object: string, - names: string[], - organizationId: string, - platformBucketNames?: Iterable, -): void { - if (names.length === 0) return; - const bucketNames = new Set(platformBucketNames ?? []); - const bucket = names.filter((n) => bucketNames.has(n)); - const residue = names.filter((n) => !bucketNames.has(n)); - - if (bucket.length > 0) { - logger?.warn?.( - `[security] organization-less ${object} rows for the PLATFORM BUCKET are visible to this ` + - `organization's pass. They are not leftovers from an older release: bootstrapPlatformAdmin ` + - `seeds these names without an organization on every boot, including the one that just ran, ` + - `and the ruling of 2026-08-20 keeps it that way (unreaped, reported, outside the ` + - `per-organization conversion) because the platform-admin grant points at the ` + - `admin_full_access row by id. This organization's own copies WERE created, so its catalog ` + - `is complete and no action is required. Re-initializing the deployment does NOT clear ` + - `them — the next boot mints them again. Adopting one by hand (stamping it with an ` + - `organization) does remove it from this list, but hands a platform-wide row to a single ` + - `tenant, so do that only if that is what you mean.`, - { - object, - organization: organizationId, - origin: 'platform-bucket' satisfies OrganizationLessRowOrigin, - names: [...bucket].sort(), - count: bucket.length, - }, - ); - } - - if (residue.length > 0) { - logger?.warn?.( - `[security] pre-fix organization-less ${object} rows are still present for names this ` + - `organization seeds — under a walled posture a row that belongs to no organization is ` + - `invalid state, not a platform-wide default. This organization's own rows WERE created, so ` + - `its catalog is complete; the leftovers below are readable through the driver's ` + - `compatibility arm and belong to nobody. Remedy: re-initialize the deployment, or adopt each ` + - `row by hand by stamping it with the organization that should own it. They are NOT deleted ` + - `automatically — grants (sys_user_position, sys_position_permission_set, ` + - `sys_user_permission_set) point at these row ids, so reaping them would revoke standing ` + - `access with no signal at the moment of loss.`, - { - object, - organization: organizationId, - origin: 'pre-fix-residue' satisfies OrganizationLessRowOrigin, - names: [...residue].sort(), - count: residue.length, - }, - ); - } -} diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 463ca038640..ed1557a8b42 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -134,8 +134,8 @@ export async function tryFind(ql: any, object: string, where: any, limit = 100, * every caller that wants "the rows I could see", wrong for anyone who has to * decide whether a row is GONE. `[]` from that helper means "no row matched OR * the read blew up", and a caller reading it as "gone" reports a deletion that - * an outage invented. `listSeedOrganizationIds` in `per-organization-catalog.ts` - * states the same rule for the same shape: zero organizations is "nothing to + * an outage invented. The retired seeder's organization enumeration + * stated the same rule for the same shape: zero organizations is "nothing to * seed", an unreadable table is "we do not know". * * So `unknown` is its own answer and the caller must decide what it means — diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index 3a7c2f33cf5..1c185efeaf6 100644 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts @@ -199,3 +199,48 @@ describe('[#18570] batched seed existence read — its own failure is audible wi expect(String(seen[0]?.[0])).toContain(BATCHED_READ_FAILED); }); }); + +/** + * [#11451, commit c33f18592] A caller that passes no `equals` predicate emits + * exactly the `where` key set it emitted before the predicate existed: + * `['name']`, not `['name']` plus `undefined`-valued keys that `toEqual` + * would quietly accept. `permission-set-projection.ts`' overlay pass is such + * a caller. The fence used to ride the declared-permission seeder's suite and + * is pinned here, at the function, now that the seeder is retiring. + * + * Both reads are pinned: the batched page and the per-item degradation read + * it falls back to. The predicate-carrying case is the positive control: it + * proves this instrument sees a spread key when one is there. + */ +describe('#11451: a caller that passes no predicate emits its old key set', () => { + it('the batched read emits only `name`', async () => { + const ql = emptyPageQl(); + await buildExistingByName(ql as any, 'sys_permission_set', ['alpha', 'beta']); + expect(ql.calls).toHaveLength(1); + expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name']); + }); + + it('the per-item degradation read emits only `name`', async () => { + const wheres: Record[] = []; + // The batched `$in` read is refused, so the per-item path is taken; each + // per-item read is recorded and answered "no such row". + const ql = { + find: async (_object: string, q?: { where?: Record }) => { + const name = q?.where?.name; + if (name && typeof name === 'object' && '$in' in (name as object)) throw new Error('batched read refused (trigger)'); + wheres.push(q?.where ?? {}); + return [] as any[]; + }, + }; + const index = await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], { warn: () => {} }); + expect((await index.get('alpha')).status).toBe('absent'); + expect(wheres).toHaveLength(1); + expect(Object.keys(wheres[0]!)).toEqual(['name']); + }); + + it('positive control: a caller that passes a predicate emits it', async () => { + const ql = emptyPageQl(); + await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], undefined, undefined, { managed_by: 'platform' }); + expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name', 'managed_by']); + }); +}); diff --git a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts index 47338c864b6..f29e4b80c68 100644 --- a/packages/plugins/plugin-security/src/seed-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/seed-write-refusal.test.ts @@ -28,7 +28,7 @@ * is the ask, fatal is not: a rethrow would turn a silent degradation into a * boot failure on every deployment carrying the legacy index; * 2. the warning is AGGREGATED — N refusals in one pass produce one line, not - * N, the same discipline `warnOrganizationLessRows` is built on (a catalog + * N, the same discipline the retired organization-less row guard was built on (a catalog * that refuses 400 rows must not print 400 warnings and bury the remedy); * 3. a NON-unique-violation refusal is not silently reclassified as one — it * gets its own line, because the migrate remedy does not apply to it and diff --git a/packages/plugins/plugin-security/src/write-refusals.ts b/packages/plugins/plugin-security/src/write-refusals.ts index 090703c15ed..2430dfda0e6 100644 --- a/packages/plugins/plugin-security/src/write-refusals.ts +++ b/packages/plugins/plugin-security/src/write-refusals.ts @@ -135,8 +135,8 @@ export interface SeedWriteRefusalReport { /** * The refusals ONE seeding pass accumulated, so the pass can report them once. * - * Aggregate-then-warn, for the same reason {@link warnOrganizationLessRows} - * aggregates: the catalog seeds every declared position, permission set and + * Aggregate-then-warn, for the same reason the retired organization-less row guard + * aggregated: the catalog seeds every declared position, permission set and * capability for every organization, and the failure this exists to surface * refuses ALL of them. A line per refused row would print hundreds of entries * into the boot log and bury the one sentence naming the remedy. One From fcaedfdaa187e10c35234ad11ef564fb3c916781 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:15:03 +0000 Subject: [PATCH 16/39] test(plugin-security): tighten the stage's new test and comment text (#15204 stage 6b-2) No assertion changes; keeps the residual under the 3,000-line ruling after the organization-scope deletion. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../src/builtin-positions.boot.test.ts | 6 +- ...ederivation-on-permission-mutation.test.ts | 69 +++++-------------- .../plugin-security/src/organization-scope.ts | 6 +- 3 files changed, 19 insertions(+), 62 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index f24a9ed8a92..2576b31d59b 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -347,11 +347,7 @@ async function boot(scenario: Scenario): Promise { } recording = false; - return { - engine, metadata, ledger, - census: await positionCensus(engine), - permissionSetCensus: await permissionSetCensus(engine), - }; + return { engine, metadata, ledger, census: await positionCensus(engine), permissionSetCensus: await permissionSetCensus(engine) }; } /** The four principals' resolved grants — needs the organization and its memberships. */ diff --git a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts index 4f0f4999f5a..5b9509d29ff 100644 --- a/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts +++ b/packages/plugins/plugin-security/src/everyone-rederivation-on-permission-mutation.test.ts @@ -2,21 +2,10 @@ /** * [#22801 → A, C3 stage 6b-2] Every landed `permission` mutation derives the - * `everyone` anchor again. - * - * The derivation used to ride the `permission` publish materializer, which - * retired with the declared-permission seeder (ADR-0131 D3: no catalog row is - * written). It now hangs off the protocol's post-persistence mutation signal - * (`onMetadataMutation`), the one choke point every authoring door funnels - * through — and unconditionally on the body: whether an accepted set still - * qualifies is `readAcceptedEveryoneSets`' question, asked of what the catalog - * now serves, so a publish that drops `isDefault` or deletes the set stops it - * granting at once. - * - * Pinned at the wiring: which events re-derive, which do not, and that a - * bootstrap re-run (the first-user replay) never registers a second listener. - * What a derivation then declares is U2's own suite - * (`suggested-audience-bindings.test.ts`, the dogfood acceptance pin). + * `everyone` anchor again, off `onMetadataMutation` (the publish materializer + * it rode retired with the seeder, ADR-0131 D3). Pinned at the wiring: which + * events re-derive, and that a bootstrap re-run never adds a second listener. + * What a derivation declares is U2's own suite. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -27,23 +16,15 @@ afterEach(() => { vi.restoreAllMocks(); }); type MutationListener = (evt: { type: string; name: string; state: 'active' | 'draft' | 'deleted' }) => void; -/** - * Field equality plus `{ $in: [...] }`. Anything else is REFUSED rather than - * answered — a combinator read as a field name matches no row and would report - * that as "no rows". - */ +/** Field equality plus `$in`; anything else is REFUSED, never read as "no rows". */ function rowMatches(row: any, where: Record = {}): boolean { return Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) { - throw new Error(`this double implements field predicates only; it cannot answer '${k}'`); - } + if (k.startsWith('$')) throw new Error(`this double cannot answer '${k}'`); const actual = row?.[k] ?? null; if (v !== null && typeof v === 'object' && !Array.isArray(v)) { const ops = Object.keys(v as Record); - if (ops.length === 1 && ops[0] === '$in') { - return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); - } - throw new Error(`this double implements equality and $in only; it cannot answer ${JSON.stringify(ops)}`); + if (ops.length === 1 && ops[0] === '$in') return ((v as any).$in as unknown[]).some((m) => (m ?? null) === actual); + throw new Error(`this double cannot answer ${JSON.stringify(ops)}`); } return actual === (v ?? null); }); @@ -55,39 +36,27 @@ async function boot() { const ql: any = { registerMiddleware: () => {}, // Only the identity objects the plugin's boot gate requires. - getSchema: (name: string) => - name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, + getSchema: (name: string) => (name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined), registry: { listItems: () => [] }, find: async (object: string, opts?: any) => { const matched = (tables[object] ?? []).filter((r) => rowMatches(r, opts?.where ?? {})); - // The caller's bound, applied BY PRESENCE and after the filter - // (`check:objectql-double-limit`). + // The caller's bound, by presence, after the filter (`check:objectql-double-limit`). return (typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched).map((r) => ({ ...r })); }, - findOne: async (object: string, query?: EngineFindOneQueryInput) => { - assertEngineFindOnePredicate(object, query); - return null; - }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }, count: async () => 0, insert: async (object: string, data: any) => { const row = { id: data?.id ?? `${object}_${(tables[object] ?? []).length + 1}`, ...data }; (tables[object] ??= []).push(row); return { ...row }; }, - update: async (_object: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - return 0; - }, + update: async (_object: string, data: any, options?: any) => { assertEngineUpdateDispatch(data, options); return 0; }, }; const listeners: MutationListener[] = []; const protocol = { - // Present so the projection wiring takes its awaited seam and adds no - // listener of its own: every listener counted below is this stage's. + // Present so the projection wiring adds no listener of its own. registerMutationProjector: vi.fn(), - onMetadataMutation: (listener: MutationListener) => { - listeners.push(listener); - return () => {}; - }, + onMetadataMutation: (listener: MutationListener) => { listeners.push(listener); return () => {}; }, }; const services: Record = { manifest: { register: vi.fn() }, @@ -99,10 +68,7 @@ async function boot() { const ctx: any = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, + getService: (name: string) => { if (!(name in services)) throw new Error(`no service: ${name}`); return services[name]; }, hook, }; const derive = vi.spyOn(SecurityPlugin.prototype as any, 'declareEveryone').mockResolvedValue(undefined); @@ -112,7 +78,6 @@ async function boot() { const fire = async (event: string) => { for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === event)) await cb(); }; - /** Let a fire-and-forget listener's promise chain settle. */ const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); }; return { ctx, fire, settle, listeners, derive }; } @@ -122,9 +87,7 @@ describe('[#22801 → A] a landed permission mutation re-derives everyone', () = const { ctx, fire, listeners } = await boot(); await fire('kernel:ready'); // Positive control: the bootstrap really completed. - expect( - ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), - ).toHaveLength(1); + expect(ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete'))).toHaveLength(1); expect(listeners).toHaveLength(1); await fire('kernel:ready'); expect(listeners).toHaveLength(1); diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index ce46e06a362..9dc7148da58 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -3,10 +3,8 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the * system context one pass runs under and the one read that tells an - * organization's own row apart from organization-less residue. [#15204 stage - * 6b-2] The boot sweep's organization enumeration and the loud guard that stood - * in place of a reap retired with the declared-permission seeder, their one - * caller; the history below is kept because the reads that remain still apply it. + * organization's own row apart from organization-less residue (the sweep and + * the reap guard retired with their one caller, the seeder; #15204 stage 6b-2). * * ## Why the catalog is materialized per organization * From 813dfed93e74e6bb2ce7b31ce15e9d23f853f55d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:30:00 +0000 Subject: [PATCH 17/39] =?UTF-8?q?test:=20the=20stage-8=20test=20half=20?= =?UTF-8?q?=E2=80=94=20suites=20stop=20depending=20on=20the=20four=20catal?= =?UTF-8?q?og=20row=20objects=20(#15204=20stage=208,=20S8-T)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every touched suite stays green on main and on main + D + S8, where sys_position, sys_permission_set, sys_position_permission_set and sys_capability are no longer registered (ADR-0131 D3). - a testkit holds the retired tables' shapes for suites that model an existing database; - pins of the retired declarations, the unwired data-door write-throughs, the projection, the row lock and the junction gates are deleted; - fixtures author permission sets through the metadata door; - prose and floor pins span the route move and the retired page; - new: a fresh-boot promotion golden (E6) and a metadata-door-reaches- the-catalog dogfood test (the premise of E1). Claude-Session: https://claude.ai/code/session_01MGoWgeE8rhsrig77V4oUZn Co-authored-by: Claude --- .../organization-ownership-inventory.test.ts | 3 +- .../test/platform-page-i18n-parity.test.ts | 19 +- packages/core/src/security/row-active.test.ts | 48 - .../validate-capability-references.test.ts | 22 +- .../protocol.packaged-base-refusal.test.ts | 10 +- ...protocol.uninstall-cleanups-runner.test.ts | 4 +- ...ata-repository.package-writability.test.ts | 34 +- .../dangling-reference-audit.test.ts | 74 +- .../tenancy-by-object-classification.test.ts | 11 +- .../canonical-expression-envelopes.test.ts | 5 +- .../src/grant-readers-by-name.golden.test.ts | 4 +- .../src/grant-readers-unnamed-grant.test.ts | 8 +- .../legacy-catalog-objects.testkit.ts | 521 ++++++++++ ...latform-admin-authenticable-target.test.ts | 2 +- ...latform-admin-existing-holder-scan.test.ts | 2 +- ...p-platform-admin-fresh-boot.golden.test.ts | 107 +++ ...platform-admin-promotion-selection.test.ts | 2 +- ...tform-admin-refused-existence-read.test.ts | 106 --- .../src/builtin-capabilities.boot.test.ts | 7 +- .../src/builtin-positions.boot.test.ts | 4 +- .../src/catalog-reference-report.test.ts | 41 +- .../claim-seed-ownership-warm-boot.test.ts | 1 - .../src/cleanup-package-permissions.test.ts | 84 +- .../src/engine-find-bare-array.pin.test.ts | 6 +- .../src/explain-asker-gate.test.ts | 6 +- .../src/explain-enforce-parity.test.ts | 6 +- .../explain-removed-member-principal.test.ts | 6 +- .../get-effective-object-permissions.test.ts | 14 +- .../grant-holder-membership-refusal.test.ts | 6 +- ...grant-permission-set-name-backfill.test.ts | 17 +- ...nt-permission-set-name.equivalence.test.ts | 6 +- .../src/grant-permission-set-name.test.ts | 6 +- .../grant-permission-set-name.writers.test.ts | 2 +- .../src/grant-readers-by-name.golden.test.ts | 6 +- .../src/grant-readers-unnamed-grant.test.ts | 6 +- .../src/granted-by-writer-provenance.test.ts | 9 +- .../identifier-storage-ceiling-pin.test.ts | 41 +- .../src/objects/clone-carryover.test.ts | 65 -- .../objects/managed-by-system-data.test.ts | 2 - .../src/objects/rbac-objects.test.ts | 159 +--- .../objects/reserved-identity-names.test.ts | 99 +- .../sys-capability-deactivation-claim.test.ts | 174 ---- ...sys-capability.organization-unique.test.ts | 64 -- ...permission-set.organization-unique.test.ts | 98 -- .../sys-position.organization-unique.test.ts | 130 --- ...-security-assignment-display-title.test.ts | 6 - .../src/orgless-position-name-fold.test.ts | 6 +- .../src/packaged-permission-set-lock.test.ts | 253 +---- .../src/per-organization-catalog.test.ts | 6 +- ...rmission-set-projection-heal-by-id.test.ts | 2 +- .../src/plugin-keyed-text-bounds.test.ts | 20 +- .../src/position-catalog-refusal.test.ts | 6 +- .../src/position-write-through.test.ts | 889 ------------------ .../src/private-credential-row-scope.test.ts | 6 +- .../src/rbac-objects-bulk-exposure.test.ts | 8 - .../src/read-fault-distinguishability.test.ts | 6 +- ...lve-authz-grant-set-by-name.golden.test.ts | 6 +- .../resolve-authz-grant-set-by-name.test.ts | 6 +- ...riable-root-comparand-fails-closed.test.ts | 6 +- .../src/scim-projection-row-scope.test.ts | 6 +- ...s-user-permission-set-window-order.test.ts | 6 +- .../src/tenant-wall-post-hook-image.test.ts | 75 +- .../src/translations/bundle-ownership.test.ts | 24 +- .../position-rename-consistency.test.ts | 9 +- .../activity-text-predicate.dogfood.test.ts | 4 +- ...-snapshot-credential-field.dogfood.test.ts | 1 - ...oval-snapshot-masked-field.dogfood.test.ts | 6 +- .../qa/dogfood/test/armed.dogfood.test.ts | 4 +- ...clared-position-provenance.dogfood.test.ts | 272 ------ .../lookup-title-exposure.dogfood.test.ts | 1 - ...apps-and-everyone-baseline.dogfood.test.ts | 32 +- ...adata-door-reaches-catalog.dogfood.test.ts | 90 ++ .../permission-set-clone-door.dogfood.test.ts | 15 +- ...on-set-lock-row-provenance.dogfood.test.ts | 269 ------ ...te-through-package-binding.dogfood.test.ts | 174 ---- ...-environment-write-through.dogfood.test.ts | 155 --- ...ion-held-name-setup-create.dogfood.test.ts | 104 -- ...aring-rule-org-less-caller.dogfood.test.ts | 18 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 12 +- ...howcase-d7-default-profile.dogfood.test.ts | 4 +- ...owcase-fls-read-mask-strip.dogfood.test.ts | 27 +- ...case-permission-projection.dogfood.test.ts | 193 ---- .../showcase-permission-zoo.dogfood.test.ts | 63 +- .../test/two-doors-permission.dogfood.test.ts | 186 ---- packages/qa/dogfood/vitest.config.ts | 1 - ...packaged-action-permission-refusal.test.ts | 4 +- ...lone-stack-seeder-declaration-copy.test.ts | 35 +- .../src/system/constants/system-names.test.ts | 5 - 88 files changed, 1122 insertions(+), 3946 deletions(-) delete mode 100644 packages/core/src/security/row-active.test.ts create mode 100644 packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/clone-carryover.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/position-write-through.test.ts delete mode 100644 packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index 9009efdc7c8..41955902457 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -86,7 +86,8 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci it('the platform registry as it stands — a platform object without a row reds here, by name', () => { const registered = Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat(); - expect(registered.length).toBeGreaterThan(80); + // (The four retired catalog row objects left the registry with C3 stage 8.) + expect(registered.length).toBeGreaterThan(75); expect(unlisted(registered)).toEqual([]); }); diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 5afc100a2e1..6e72ab476d9 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -788,9 +788,10 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // red here. What this refuses is the scan that finds NOTHING -- an empty // population would satisfy every `for` loop below and report success over // zero pages, which is the exact shape of the `0` that reads as "clean". - expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(3); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(2); expect(RECORD_PAGES.map((p) => p.name).sort()).toEqual( - expect.arrayContaining(['sys_organization_detail', 'sys_position_detail', 'sys_user_detail']), + expect.arrayContaining(['sys_organization_detail', 'sys_user_detail']), ); expect(SHIPPED_LOCALES).toContain(EN); expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); @@ -839,7 +840,8 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // Same refusal as the population floor: zero maps means the walk broke, not // that the pages went monolingual. - expect(maps.length).toBeGreaterThanOrEqual(45); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(maps.length).toBeGreaterThanOrEqual(40); const incomplete = maps .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) @@ -1040,14 +1042,14 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 // The anti-vacuity floor, stated before the verdict runs. A verdict over an // empty or shrunken population is the ambiguous `0` this whole card is // about: every loop below would be satisfied and report success. - expect(SOURCE_BY_NAME.size).toBeGreaterThanOrEqual(6); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(SOURCE_BY_NAME.size).toBeGreaterThanOrEqual(5); expect([...SOURCE_BY_NAME.keys()].sort()).toEqual( expect.arrayContaining([ 'cloud_connection_settings', 'connect_agent', 'marketplace_installed', 'sys_organization_detail', - 'sys_position_detail', 'sys_user_detail', ]), ); @@ -1062,7 +1064,7 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 const enPages = pagesOf(EN); // Absence is loud: an `en` section that lost its entries would make the // verdict below compare nothing at all. - expect(Object.keys(enPages).length).toBeGreaterThanOrEqual(6); + expect(Object.keys(enPages).length).toBeGreaterThanOrEqual(5); const findings = defaultLocalePageDrift(enPages, SOURCE_BY_NAME).map((f) => ({ ...f, @@ -1201,10 +1203,11 @@ describe('platform record page labels are translated in every shipped locale (#1 * wrong recorded source hash would serve the English source here and red this * assertion rather than shipping a silent regression. */ - const RECORD_PAGE_NAMES = ['sys_user_detail', 'sys_organization_detail', 'sys_position_detail']; + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + const RECORD_PAGE_NAMES = ['sys_user_detail', 'sys_organization_detail']; const TRANSLATED_LOCALES = SHIPPED_LOCALES.filter((locale) => locale !== EN); - it('has a real, non-English label for each of the three in each translated locale', () => { + it('has a real, non-English label for each record page in each translated locale', () => { expect(TRANSLATED_LOCALES.length).toBeGreaterThanOrEqual(3); const english = pagesOf(EN); diff --git a/packages/core/src/security/row-active.test.ts b/packages/core/src/security/row-active.test.ts deleted file mode 100644 index 83118f95b34..00000000000 --- a/packages/core/src/security/row-active.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { isRowActive } from './row-active.js'; - -/** - * [#8613 / ADR-0049] The ONE predicate three readers share — the core resolver - * that enforces `active`, the plugin-security loader that judges the same flag - * on the name-reuse path, and the break-glass guard that SIMULATES a write to - * it. The reason it is one function is that a guard modelling "deactivated" - * differently from the resolver would permit exactly the write it exists to - * refuse. - * - * Both directions are pinned deliberately, because both have a real failure - * mode: reading absent as inactive mass-revokes deployed rows, and reading only - * `=== false` as inactive misses the shape SQLite actually stores. - */ -describe('[#8613] isRowActive — explicitly deactivated, not explicitly active', () => { - it('treats the stored spellings of OFF as deactivated', () => { - for (const off of [false, 0, '0', 'false']) { - expect(isRowActive({ active: off })).toBe(false); - } - }); - - it('treats the stored spellings of ON as active', () => { - for (const on of [true, 1, '1', 'true']) { - expect(isRowActive({ active: on })).toBe(true); - } - }); - - it('ABSENT means active — a row predating the column keeps granting', () => { - expect(isRowActive({})).toBe(true); - expect(isRowActive({ active: undefined })).toBe(true); - expect(isRowActive({ active: null })).toBe(true); - }); - - it('an unrecognised value is NOT a deactivation — junk may not revoke', () => { - // Nothing writes these. Inventing a revocation out of unreadable data is - // the direction that ends in a lockout nobody ordered. - expect(isRowActive({ active: 'yes' } as never)).toBe(true); - expect(isRowActive({ active: {} } as never)).toBe(true); - }); - - it('a missing row is not active — a filter over nothing grants nothing', () => { - expect(isRowActive(undefined)).toBe(false); - expect(isRowActive(null)).toBe(false); - }); -}); diff --git a/packages/lint/src/validate-capability-references.test.ts b/packages/lint/src/validate-capability-references.test.ts index e9865687e0d..9800b62959b 100644 --- a/packages/lint/src/validate-capability-references.test.ts +++ b/packages/lint/src/validate-capability-references.test.ts @@ -44,14 +44,6 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => { expect(findings).toEqual([]); }); - it('passes a reference to a capability shipped as a sys_capability seed row', () => { - const findings = validateCapabilityReferences({ - data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }], - objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], - }); - expect(findings).toEqual([]); - }); - it('warns on an object requiredPermissions typo (registered nowhere)', () => { const findings = validateCapabilityReferences({ objects: [{ name: 'sys_license', requiredPermissions: ['mange_users'] }], @@ -193,10 +185,11 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { const findings = validateCapabilityReferences({ objects: [{ name: 'sys_license', requiredPermissions: ['mange_users'] }], }); - expect(findings.map((f) => f.message)).toEqual([ - 'requiredPermissions names capability "mange_users", which no built-in, `defineCapability`, ' - + '`systemPermissions` grant or `sys_capability` seed provides, so this gate fails closed', - ]); + expect(findings).toHaveLength(1); + const [message] = findings.map((f) => f.message); + expect(message.startsWith('requiredPermissions names capability "mange_users", which no built-in, `defineCapability`')).toBe(true); + expect(message.endsWith('so this gate fails closed')).toBe(true); + expect(message.split('\n')).toHaveLength(1); }); it('`os explain` carries what the verdict no longer says', () => { @@ -218,10 +211,10 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { }); it('each source of the known set the explanation names silences the rule', () => { - // The explanation module imports nothing, so its four sources are held to + // The explanation module imports nothing, so its sources are held to // the rule by running the rule: each one makes the same reference known. const text = explainRule(CAPABILITY_REFERENCE_UNKNOWN)!.paragraphs.join('\n'); - for (const named of ['built-in platform capability', '`defineCapability`', '`systemPermissions`', '`sys_capability` seed row']) { + for (const named of ['built-in platform capability', '`defineCapability`', '`systemPermissions`']) { expect(text, named).toContain(named); } const gated = { objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }] }; @@ -229,7 +222,6 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { for (const source of [ { capabilities: [{ name: 'approve_invoice', label: 'Approve', scope: 'org' }] }, { permissions: [{ name: 'p', systemPermissions: ['approve_invoice'] }] }, - { data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }] }, ]) { expect(validateCapabilityReferencesUnrecorded({ ...gated, ...source }), JSON.stringify(source)).toEqual([]); } diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index 1bba1f2c3f9..448425c6a34 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -369,10 +369,10 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa it(`\`permission\` — save names its clone and no switch (${environmentId ? 'environment' : 'host-config'} kernel)`, () => { const message = expectRegimeC(refusalOn(environmentId, 'permission', 'pkg_perm', 'save'), 'permission', 'pkg_perm', 'save'); - expect(message).toContain( - 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).', - ); + // The clone route's spelling moved with C3 stage 8 (the retired + // object's data route → the catalog's clone door); the sentence did not. + expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); + expect(message).toContain(' with a new name).'); expect(message.toLowerCase()).not.toContain('switch it off'); expect(message).not.toContain('_activation'); expect(message).not.toContain('/automation/'); @@ -411,7 +411,7 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa for (const [type, name, path] of [ ['flow', 'pkg_flow', 'POST /api/v1/automation/:name/clone'], ['action', 'pkg_action', 'POST /api/v1/actions/_activation/:object/:action'], - ['permission', 'pkg_perm', 'POST /api/v1/data/sys_permission_set'], + ['permission', 'pkg_perm', 'the "Clone" action on the permission set, or POST /api/v1/'], ] as const) { const r: any = p.packagedBaseRefusal({ type, name, operation: 'save', packageId: PACKAGE_ID }); expect({ code: r?.code, status: r?.status, lockSource: r?.lockSource, packageId: r?.packageId }, type) diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index 02350f17f4c..409da59850b 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,7 +99,9 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - expect(runner.mock.calls).toEqual([[request]]); + // Its own request — plus, since C3 stage 8, the catalog items the package + // declared (`declared`), read before its withdrawal. + expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index 4c985c5cc9b..89fdb8967fc 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -574,10 +574,11 @@ describe('#7682 — the refusal discriminates on package writability', () => { `Cannot overlay 'permission' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + 'is locked against in-place edits. ', )).toBe(true); - expect(message).toContain( - 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).', - ); + // The clone route's spelling moved with C3 stage 8 (the data route of the + // retired `sys_permission_set` object → the catalog's clone door); the + // sentence around it did not. + expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); + expect(message).toContain(' with a new name).'); expect(message).not.toContain('OS_METADATA_WRITABLE'); expect(message).not.toContain('redeploy'); expect(message.endsWith('See docs/adr/0126-packaged-metadata-customization-model.md.')).toBe(true); @@ -1253,8 +1254,17 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const ADR_0126 = 'See docs/adr/0126-packaged-metadata-customization-model.md.'; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, ' + ':object = global for an object-less action; operator-only where one install serves several organizations).'; + // `{ROUTE}`: the clone route, whose spelling moved with C3 stage 8 (the + // retired object's data route → the catalog's clone door); see `sentenceMatches`. const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).'; + + 'or POST {ROUTE} with a new name).'; + /** Exact equality, with a `{ROUTE}` placeholder standing for one `/api/v1/…` path. */ + const sentenceMatches = (actual: string, expected: string): boolean => { + const [head, tail] = expected.split('{ROUTE}'); + if (tail === undefined) return actual === expected; + return actual.startsWith(head) && actual.endsWith(tail) + && /^\/api\/v1\/\S+$/.test(actual.slice(head.length, actual.length - tail.length)); + }; const FLOW_PATHS = 'Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), ' + 'or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations).'; @@ -1286,10 +1296,9 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, it(`type door, save: a packaged ${type} is refused NOT_OVERRIDABLE with its row's path, no hatch`, async () => { const err = await putWith(repo, { type, name: `pkg_${type}`, intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(String(err.message)).toBe( - `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` - + `against in-place edits. ${paths} ${ADR_0126}`, - ); + const expected = `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + + `against in-place edits. ${paths} ${ADR_0126}`; + expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); expect(Array.from(engine.rows.values())).toEqual([]); }); } @@ -1359,10 +1368,9 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG, docs: 'docs/adr/0010-metadata-protection-model.md', }); - expect(String(err.message)).toBe( - `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` - + `is locked against in-place edits. ${paths} ${ADR_0126}`, - ); + const expected = `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + + `is locked against in-place edits. ${paths} ${ADR_0126}`; + expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); }); } diff --git a/packages/objectql/src/integrity/dangling-reference-audit.test.ts b/packages/objectql/src/integrity/dangling-reference-audit.test.ts index 92911b1033e..356e409bfaa 100644 --- a/packages/objectql/src/integrity/dangling-reference-audit.test.ts +++ b/packages/objectql/src/integrity/dangling-reference-audit.test.ts @@ -32,9 +32,13 @@ import { type DanglingReferenceAuditPort, } from './dangling-reference-audit.js'; -/** The RBAC link-table shape #4551 calls out as the priority case. */ +/** + * The RBAC link-table shape #4551 calls out as the priority case. Named for a + * security table that survives the catalog row objects' retirement (C3 stage 8: + * `sys_position_permission_set` is gone); the shape is the fixture's own. + */ const binding: AuditableObject = { - name: 'sys_position_permission_set', + name: 'sys_user_permission_set', fields: { id: { type: 'text', primaryKey: true }, position_id: { type: 'lookup', reference: 'sys_position' }, @@ -123,7 +127,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_real', permission_set_id: 'ps_does_not_exist_at_all' }, ], }, @@ -138,7 +142,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () // Which object, which record, which field, which id, which target object — // the report is addressed to a human who has to go fix it. expect(out.dangling[0]).toEqual({ - objectName: 'sys_position_permission_set', + objectName: 'sys_user_permission_set', recordId: 'ppr_1', field: 'permission_set_id', target: 'sys_permission_set', @@ -150,7 +154,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_real', permission_set_id: 'ps_real' }, ], }, @@ -170,7 +174,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_1', permission_set_id: 'ps_1' }, ], }, @@ -189,7 +193,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_1', permission_set_id: 'ps_1' }, ], }, @@ -205,12 +209,12 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding, task], rows: { showcase_task: [{ id: 't1', title: 'T', project: 'proj_real' }] }, - unreadable: new Set(['sys_position_permission_set']), + unreadable: new Set(['sys_user_permission_set']), existing: new Set(['showcase_project proj_real']), }); const out = await auditDanglingReferences(port); - expect(out.unreadableObjects).toEqual(['sys_position_permission_set']); + expect(out.unreadableObjects).toEqual(['sys_user_permission_set']); expect(out.dangling).toEqual([]); }); @@ -219,7 +223,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () // the stored data disagree with what actually happened, and the remedy // (re-seed the target vs clear the link) is an operator judgement call. const rows: Record>> = { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_gone', permission_set_id: 'ps_gone' }, ], showcase_task: [ @@ -305,17 +309,17 @@ describe('[#4551] dangling stored references are reported, never rewritten', () objects: [task, binding], // registration order puts the business object first rows: { showcase_task: [{ id: 't1', title: 'T', project: 'p' }], - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_1' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_1' }], }, }); const findSpy = port.find.bind(port); port.find = async (o, opts) => { reads.push(o); return findSpy(o, opts); }; await auditDanglingReferences(port); - expect(reads[0]).toBe('sys_position_permission_set'); + expect(reads[0]).toBe('sys_user_permission_set'); // …and the priority set is DERIVED from the platform-object registry, not // hand-listed here, so a new plugin-security table is covered for free. - expect(SECURITY_SURFACE_OBJECTS.has('sys_position_permission_set')).toBe(true); + expect(SECURITY_SURFACE_OBJECTS.has('sys_user_permission_set')).toBe(true); expect(SECURITY_SURFACE_OBJECTS.has('sys_user_permission_set')).toBe(true); }); @@ -344,7 +348,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'a', permission_set_id: 'ps_gone' }, { id: 'b', permission_set_id: 'ps_gone' }, { id: 'c', permission_set_id: 'ps_gone' }, @@ -362,7 +366,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () it('the report is logged when there is anything to say, and silent otherwise', async () => { const clean = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); await auditDanglingReferences(clean); @@ -370,13 +374,13 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const dirty = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, }); await auditDanglingReferences(dirty); expect(dirty.warnings).toHaveLength(1); expect(dirty.warnings[0][0]).toContain('reported, never rewritten'); expect((dirty.warnings[0][1] as any).references).toEqual([ - 'sys_position_permission_set#ppr_1.permission_set_id → sys_permission_set#ps_gone', + 'sys_user_permission_set#ppr_1.permission_set_id → sys_permission_set#ps_gone', ]); }); }); @@ -402,7 +406,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const unreadablePort = () => makePort({ objects: [binding], rows: {}, - unreadable: new Set(['sys_position_permission_set']), + unreadable: new Set(['sys_user_permission_set']), }); it('a REAL datasource fault still lands in `unreadableObjects`, loudly', async () => { @@ -413,7 +417,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const out = await auditDanglingReferences(port); - expect(out.unreadableObjects).toEqual(['sys_position_permission_set']); + expect(out.unreadableObjects).toEqual(['sys_user_permission_set']); expect(out.aborted).toBe(false); expect(port.warnings.map((w) => w[0])).toContain( '[integrity] dangling-reference audit could not list an object', @@ -448,7 +452,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const reads: string[] = []; const port = makePort({ objects: [binding, task], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, }); const findSpy = port.find.bind(port); port.find = async (o, opts) => { reads.push(o); return findSpy(o, opts); }; @@ -471,7 +475,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const port = makePort({ objects: [binding, task], // binding is scanned first (security surface) rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], }, unreadable: new Set(['showcase_task']), @@ -487,7 +491,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const out = await auditDanglingReferences(port, { signal }); - expect(reads).toEqual(['sys_position_permission_set', 'showcase_task']); + expect(reads).toEqual(['sys_user_permission_set', 'showcase_task']); expect(out.dangling).toHaveLength(1); expect(out.dangling[0]).toMatchObject({ recordId: 'ppr_1', value: 'ps_gone' }); expect(out.aborted).toBe(true); @@ -507,7 +511,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const signal = { aborted: false }; const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, throwingTargets: new Set(['sys_permission_set']), }); const probeSpy = port.probe.bind(port); @@ -528,7 +532,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () // has to guess whether `undefined` meant "finished" or "old report shape". const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); @@ -673,7 +677,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => rows: { showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_deleted' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], }, }); const findSpy = port.find.bind(port); @@ -681,7 +685,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => await auditDanglingReferences(port); - expect(reads).toEqual(['sys_position_permission_set', 'showcase_task', 'showcase_note']); + expect(reads).toEqual(['sys_user_permission_set', 'showcase_task', 'showcase_note']); }); it('provenance ALONE does not raise the summary warning', async () => { @@ -731,7 +735,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => // guess whether `undefined` meant "clean" or "old report shape". const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); @@ -764,7 +768,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const budgetPort = (objects: AuditableObject[]) => makePort({ objects, rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_real' }], showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_alive' }], }, @@ -785,7 +789,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const out = await auditDanglingReferences(port, { maxRows: 1 }); - expect(reads).toEqual(['sys_position_permission_set']); // budget bought one table + expect(reads).toEqual(['sys_user_permission_set']); // budget bought one table expect(out.scanned).toBe(1); // …and the report SAYS the other two were never opened, instead of leaving // them inside an empty `dangling`. @@ -825,7 +829,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const out = await auditDanglingReferences(port, { maxRows: 1, - objects: ['sys_position_permission_set', 'showcase_task'], + objects: ['sys_user_permission_set', 'showcase_task'], }); // `showcase_note` was never on this run's list, so it is not something the @@ -845,7 +849,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', expect(out.scanned).toBe(0); expect(out.dangling).toEqual([]); // …and this says NOTHING, which is the point expect(out.unscannedObjects).toEqual([ - 'sys_position_permission_set', 'showcase_task', 'showcase_note', + 'sys_user_permission_set', 'showcase_task', 'showcase_note', ]); }); @@ -858,7 +862,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const signal = { aborted: false }; const port = makePort({ objects: [binding, task, note], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, unreadable: new Set(['showcase_task']), }); const findSpy = port.find.bind(port); @@ -884,7 +888,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const signal = { aborted: false }; const port = makePort({ objects: [binding, task, note], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, // The probe blows up because the pool went away under it — the #4747 // race, which is what makes the answer "withdrawn" rather than a verdict. throwingTargets: new Set(['sys_permission_set']), @@ -896,7 +900,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', expect(out.aborted).toBe(true); expect(out.unscannedObjects).toEqual(['showcase_task', 'showcase_note']); - expect(out.unscannedObjects).not.toContain('sys_position_permission_set'); + expect(out.unscannedObjects).not.toContain('sys_user_permission_set'); }); it('called off BEFORE the registry was enumerated: `aborted` carries it, the list is empty', async () => { @@ -949,7 +953,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const loud = makePort({ objects: [binding, task, note], rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_deleted' }], }, diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 9049f30940b..ed6d68fef2f 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -134,12 +134,17 @@ describe('#13491 the inventory — a verdict per object, never a namespace', () }); it('#8672 inherits PER OBJECT — the same namespace holds both verdicts', () => { - expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('global'); expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); // The discriminating half: the old blanket predicate answered the SAME - // thing for both of these, which is the exemption the ruling withdrew. - expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); + // thing for every platform object, which is the exemption the ruling + // withdrew. Each verdict the inventory records is honoured as recorded — + // read off the inventory, since its one `global` entry + // (`sys_permission_set`) retired with its object (C3 stage 8). expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); + for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { + expect(classifyPlatformObjectTenancy(name), name).toBe(entry.tenancy); + expect(isPlatformObjectOutOfTenantAuditScope(name), name).toBe(entry.tenancy !== 'tenant-scoped'); + } }); it('the exemption applies to platform namespaces only — an application object was never in it', () => { diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index 6791bc618bd..42b2b6d0480 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -183,8 +183,9 @@ describe('platform Page exports serve canonical expression envelopes', () => { ).toBe(''); // Population floor: the gate is worthless if it silently reads nothing. - expect(EXPORTED_PAGES.length).toBeGreaterThanOrEqual(3); - expect(declared.length).toBeGreaterThanOrEqual(3); + // (The `sys_position` record page retired with its object, C3 stage 8.) + expect(EXPORTED_PAGES.length).toBeGreaterThanOrEqual(2); + expect(declared.length).toBeGreaterThanOrEqual(2); }); it.each(AUDITS)('$exportName parses through PageSchema (door 1 precondition)', ({ audit }) => { diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 93ef93b5a02..c7efb433423 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -155,7 +155,9 @@ async function standingByReader(posture: Posture): Promise { const { engine } = await soleGrantAnchoredAdmin(); const grant = await adminGrant(engine); - const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[]; - await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id, reason: 'renewed' }, SYS); + // The id of another set's legacy catalog row — read off the row where that + // object is still registered, a literal one once it is retired (C3 stage 8). + const viewerId = engine.getObject?.('sys_permission_set') + ? ((await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[])[0]?.id + : 'ps_legacy_viewer_readonly'; + await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewerId, reason: 'renewed' }, SYS); expect(await adminGrant(engine)).toMatchObject({ permission_set: 'admin_full_access', reason: 'renewed' }); }); }); diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts new file mode 100644 index 00000000000..fe0d34d4ce7 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts @@ -0,0 +1,521 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the TABLE shapes of the four retired security-catalog row + * objects — `sys_position`, `sys_capability`, `sys_permission_set` and + * `sys_position_permission_set` (ADR-0131 D3; retired from the plugin by C3 + * stage 8). Fields, indexes and validations only, as the last release that + * declared them stored them. + * + * A suite that models an EXISTING database — rows a release before the + * retirement wrote, which stay in the physical tables until the + * organization-ownership migration (ADR-0131 D10) takes them — registers these + * on its own engine. No runtime code declares them any more, and a suite that + * only boots the platform registers none of them. + */ + +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { P } from '@objectstack/spec'; +import { F } from '@objectstack/spec'; +import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from '../objects/reserved-identity-names.js'; + +const RESERVED_IDENTITY_NAME_CONDITION = + `record.name in ${reservedIdentityNamesCelList()}` + + ` && !(record.managed_by in ['platform', 'system'])`; + +export const SysPosition = ObjectSchema.create({ + name: 'sys_position', + label: 'Position', + pluralLabel: 'Positions', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: + 'Machine name for the position, unique per organization ' + + '(e.g. sales_manager, hr_specialist)', + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + group: 'Status', + }), + + is_default: Field.boolean({ + label: 'Default Position', + defaultValue: false, + description: 'Automatically assigned to new users', + group: 'Status', + }), + + delegatable: Field.boolean({ + label: 'Delegatable', + defaultValue: false, + description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.', + group: 'Status', + }), + + managed_by: Field.select({ + label: 'Managed By', + readonly: true, + defaultValue: 'admin', + description: + 'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' + + 'permission sets and positions all share: platform = framework built-in ' + + '(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' + + 'may carry system (== platform) / config (== package) / user (== admin).', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'System', + }), + + id: Field.text({ + label: 'Position ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, + + validations: [ + { + type: 'cross_field', + name: 'reserved_identity_name', + label: 'Reserved built-in identity name', + description: + 'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own ' + + 'catalog seed may spell one; a tenant- or package-authored position must choose another name.', + fields: ['name', 'managed_by'], + condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION }, + severity: 'error', + message: reservedIdentityNameMessage('name'), + }, + ], +}); + +export const SysCapability = ObjectSchema.create({ + name: 'sys_capability', + label: 'Capability', + pluralLabel: 'Capabilities', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: 'Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access).', + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + scope: Field.select({ + label: 'Scope', + required: true, + defaultValue: 'platform', + description: 'platform = a platform-wide power; org = scoped to an organization.', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'org', label: 'Organization' }, + ], + group: 'Classification', + }), + + managed_by: Field.select({ + label: 'Managed By', + required: true, + defaultValue: 'admin', + description: 'platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'Classification', + }), + + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: 'Package that ships this capability (absent = platform-curated or admin-created).', + group: 'Classification', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + description: + 'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.', + group: 'Status', + }), + + id: Field.text({ + label: 'Capability ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['scope'] }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +export const SysPermissionSet = ObjectSchema.create({ + name: 'sys_permission_set', + label: 'Permission Set', + pluralLabel: 'Permission Sets', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: + 'Machine name for the permission set, unique per organization. This is the set’s ' + + 'metadata identity (ADR-0094) and cannot be changed after creation — the data door ' + + 'rejects a rename; clone the set to a new name instead.', + readonlyWhen: P`record.id != null && record.id != ''`, + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + object_permissions: Field.textarea({ + label: 'Object Permissions', + required: false, + description: 'JSON-serialized object-level CRUD permissions', + group: 'Permissions', + }), + + field_permissions: Field.textarea({ + label: 'Field Permissions', + required: false, + description: 'JSON-serialized field-level read/write permissions', + group: 'Permissions', + }), + + system_permissions: Field.textarea({ + label: 'System Permissions', + required: false, + description: 'JSON-serialized array of system capability names (e.g. ["setup.access","studio.access","manage_users"])', + group: 'Permissions', + }), + + row_level_security: Field.textarea({ + label: 'Row-Level Security', + required: false, + description: 'JSON-serialized array of row-level security policies (USING/CHECK clauses)', + group: 'Permissions', + }), + + tab_permissions: Field.textarea({ + label: 'Tab Permissions', + required: false, + description: 'JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)', + group: 'Permissions', + }), + + admin_scope: Field.textarea({ + label: 'Delegated Admin Scope', + required: false, + description: + '[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, ' + + 'manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the ' + + 'user a SCOPED administrator within the declared business-unit subtree; enforced by the ' + + 'delegated-admin write gate.', + group: 'Permissions', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + group: 'Status', + }), + + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: + 'Package that ships this permission set (absent = environment-authored). ' + + 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', + group: 'Provenance', + }), + + managed_by: Field.select({ + label: 'Managed By', + required: false, + readonly: true, + defaultValue: 'admin', + description: + "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + + "permission sets and positions all share: 'platform' = shipped by the " + + "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + + "for admins); 'admin' = created/owned in this environment by an administrator " + + "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'Provenance', + }), + + customized: Field.boolean({ + label: 'Customized', + defaultValue: false, + readonly: true, + description: + 'This packaged permission set has an environment customization overlay (ADR-0094). ' + + 'Reset it (delete through the data door) to return to the shipped baseline.', + group: 'Provenance', + }), + + drift_status: Field.select({ + label: 'Declaration Drift', + required: false, + readonly: true, + description: + "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + + "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + + "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + + "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + + "shipped artifact, or this set is not currently package-declared.", + options: [ + { value: 'overlay_shadow', label: 'Overlay shadow' }, + { value: 'provenance_skip', label: 'Provenance skip' }, + { value: 'other', label: 'Drift (other)' }, + ], + group: 'Provenance', + }), + + drift_detail: Field.textarea({ + label: 'Drift Detail', + required: false, + readonly: true, + description: 'Human-readable detail for `drift_status` — names the grant-count mismatch and its cause.', + group: 'Provenance', + }), + + id: Field.text({ + label: 'Permission Set ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +export const SysPositionPermissionSet = ObjectSchema.create({ + name: 'sys_position_permission_set', + label: 'Position Permission Set', + pluralLabel: 'Position Permission Sets', + isSystem: true, + managedBy: 'system-data', + displayNameField: 'display_title', + nameField: 'display_title', + fields: { + id: Field.text({ + label: 'Binding ID', + required: true, + readonly: true, + description: 'UUID of the position-permission-set binding.', + }), + + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.position_id + ' → ' + record.permission_set_id`, + description: 'Record title: the position and the permission set bound to it (computed on read)', + }), + + position_id: Field.lookup('sys_position', { + label: 'Position', + required: true, + description: 'Foreign key to sys_position.', + }), + + permission_set_id: Field.lookup('sys_permission_set', { + label: 'Permission Set', + required: true, + description: 'Foreign key to sys_permission_set.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['position_id', 'permission_set_id'], unique: 'global' }, + { fields: ['position_id'] }, + { fields: ['permission_set_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +/** The four retired table shapes above. */ +export const LEGACY_CATALOG_OBJECTS = [SysPosition, SysCapability, SysPermissionSet, SysPositionPermissionSet] as const; + +/** + * `objects` plus every retired catalog table it does not already declare — for + * a suite that boots the plugin's own objects AND models an existing database's + * catalog rows, whether or not the plugin still declares those tables. + */ +export function withLegacyCatalogTables(objects: readonly T[]): Array { + const declared = new Set(objects.map((o) => o.name)); + return [...objects, ...LEGACY_CATALOG_OBJECTS.filter((o) => !declared.has(o.name))]; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 42c76eb2cad..b826db26169 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -50,7 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index 6ab7ae983cb..61b13fcb7f4 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -79,7 +79,7 @@ import { PLATFORM_ADMIN_GRANT_PAGE_SIZE, PLATFORM_ADMIN_GRANT_SCAN_CEILING, } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts new file mode 100644 index 00000000000..a49c03de00e --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A fresh `single`-posture database promotes its first human — on an engine + * that carries exactly the objects the security plugin registers, and nothing + * an earlier release left behind. + * + * The bootstrap's "already have an administrator?" question has a leg for a + * grant written before `sys_user_permission_set.permission_set` existed (it + * names nothing yet, so the by-name legs cannot see it; ADR-0131 D4). That leg + * used to reach such a grant through the legacy `admin_full_access` catalog + * row, read with "refused is not absent": a read that threw withheld the + * promotion. Once the catalog row objects are retired (ADR-0131 D3, C3 stage 8) + * a read of that row throws on EVERY boot, so a leg that still asked it would + * withhold the first administrator from every fresh deployment, forever. This + * suite registers the plugin's own objects only — no legacy table — so that + * regression is a red here. + * + * Its counterpart, the grant that names nothing and must still keep a second + * administrator from being minted, is pinned in + * `grant-readers-unnamed-grant.test.ts`. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { securityObjects } from './manifest.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as const; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; + +const engines: ObjectQL[] = []; + +beforeEach(() => { + delete process.env[OWNER_ENV]; + delete process.env[POSTURE_ENV]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + while (engines.length) { + try { + await engines.pop()?.destroy(); + } catch { + /* noop */ + } + } + resetPlatformAdminEmailMemo(); +}); + +/** A fresh `:memory:` database holding the security plugin's own objects and the identity tables. */ +async function freshEngine(): 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.qa.fresh-boot-promotion', + name: 'Fresh boot promotion', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, ...securityObjects], + } as any); + await engine.syncSchemas(); + engines.push(engine); + return engine; +} + +async function human(engine: ObjectQL, id: string, email: string, createdAt: string): Promise { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt }, SYS); + await engine.insert('sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, SYS); +} + +describe('[ADR-0131 D3/D4] a fresh single-posture database promotes its first human', () => { + it('promotes the oldest account that can sign in, granting admin_full_access by name', async () => { + const engine = await freshEngine(); + await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); + await human(engine, 'usr_second', 'second@fresh.example', '2026-02-01T00:00:00.000Z'); + + const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + + expect(report).toMatchObject({ adminPromoted: true, adminUserId: 'usr_first' }); + const grants = (await engine.find('sys_user_permission_set', { where: {}, ...SYS })) as any[]; + expect(grants.map((g) => [g.user_id, g.permission_set, g.organization_id ?? null])).toEqual([ + ['usr_first', 'admin_full_access', null], + ]); + }); + + it('a second pass finds the administrator it promoted and promotes nobody else', async () => { + const engine = await freshEngine(); + await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); + expect((await bootstrapPlatformAdmin(engine, defaultPermissionSets)).adminPromoted).toBe(true); + await human(engine, 'usr_later', 'later@fresh.example', '2026-03-01T00:00:00.000Z'); + + const again = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + + expect(again).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_first' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 0d0fec1689c..446339ba5f5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -70,7 +70,7 @@ import { PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts deleted file mode 100644 index d57f5e44193..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#22169] `bootstrapPlatformAdmin`'s existence read: a read that did not - * answer is not an absent row. - * - * The seed loop reads each default permission set BY NAME and inserts when it - * finds none. Its `tryFind` answers `[]` for a refused read as well as for an - * empty table, and the loop inserted on both — so on a table without the - * per-organization unique index a refused read minted a second row of a set - * that already existed, and a table holding such duplicates can never build - * that index again. Measured on a real engine with that index dropped: two - * declared sets, two INSERTs, under a refused read. - * - * The double below has no `update` or `delete` member — the seed pass without - * `resync` calls neither — so it is not an engine double those dispatch - * contracts govern. Its `find` answers from its rows, bounded by the caller's - * `limit`; the refused read is a spy laid over it, so the two inputs the pass - * must tell apart — an answer, and a throw — come from one table. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; - -afterEach(() => { vi.restoreAllMocks(); }); - -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: any[] = []; - return { - rows, - inserted, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return r[k] === v; - })); - return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - inserted.push({ ...data }); - rows.push({ ...data }); - return { id: data.id }; - }, - }; -} - -/** Every `sys_permission_set` read now throws; every other read still answers. */ -function refuseSetReads(ql: ReturnType): void { - const answer = ql.find.bind(ql); - vi.spyOn(ql, 'find').mockImplementation(async (object: string, q: any) => { - if (object === 'sys_permission_set') throw new Error('fake outage: sys_permission_set read refused'); - return answer(object, q); - }); -} - -const set = (name: string) => ({ name, label: name, objects: {}, systemPermissions: [] }) as any; - -function capture() { - const warns: Array<{ msg: string; meta?: any }> = []; - return { warns, logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; -} - -describe('#22169 — bootstrapPlatformAdmin declines the insert when its existence read was refused', () => { - it('a refused read inserts NOTHING, and says so once for the whole pass', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'admin_full_access' }]); - refuseSetReads(ql); - const { warns, logger } = capture(); - - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default'), set('viewer_readonly')], { logger }); - - expect(ql.inserted, '⛔ no row minted on a read that did not answer').toEqual([]); - expect(r.seeded).toBe(0); - const lines = warns.filter((w) => w.msg.includes('could not be read')); - expect(lines, 'one line per pass, never one per set').toHaveLength(1); - expect(lines[0]!.meta).toMatchObject({ - unreadable: 3, total: 3, names: ['admin_full_access', 'member_default', 'viewer_readonly'], - }); - // The promotion half reports the read that failed, not a set that is "missing". - expect(r.reason).toBe('admin_permission_set_unreadable'); - }); - - it('control: an EMPTY answer is still "absent" — a fresh install seeds every default set', async () => { - const ql = makeQl(); - const { warns, logger } = capture(); - - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - - expect(ql.inserted.map((row) => row.name)).toEqual(['admin_full_access', 'member_default']); - expect(r.seeded).toBe(2); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); - - it('control: an existing row is found and left alone — no insert, no unreadable line', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'member_default' }]); - const { warns, logger } = capture(); - - await bootstrapPlatformAdmin(ql, [set('member_default')], { logger }); - - expect(ql.inserted).toEqual([]); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); -}); 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..ae416c5c67c 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -184,8 +184,13 @@ async function boot(filename: string, posture: Posture, declared: Declared): Pro return { engine, metadata, warnings, writes }; } -/** The `sys_capability` rows, one string per row. */ +/** + * The `sys_capability` rows, one string per row. [C3 stage 8] Once the row + * object is retired (ADR-0131 D3) no such table is registered, so there is no + * row to count. + */ async function census(engine: ObjectQL): Promise { + if (!(engine as any).getObject?.('sys_capability')) return []; const rows = (await engine.find('sys_capability', { where: {}, limit: 5000, context: SYS })) as any[]; return rows .map((r) => [r.name, r.managed_by, r.package_id ?? '-', r.label, r.description ?? '-', r.scope, r.active].join(' | ')) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a0291771532..3cf73127a45 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -64,6 +64,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; +import { withLegacyCatalogTables } from './__tests__/legacy-catalog-objects.testkit.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; const SYS = { isSystem: true } as const; @@ -214,7 +215,8 @@ async function boot(scenario: Scenario): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + // The retired catalog tables too: an existing database still holds their rows. + objects: withLegacyCatalogTables([SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects]), } as any); await engine.syncSchemas(); if (scenario.authoredPosition) { diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index f1d2df6383e..4378b81ddfe 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -39,10 +39,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { @@ -393,30 +393,13 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names const report = await b.report(); expect(report.conflictingPositionNames).toEqual([ { name: PKG_POSITION, organizationIds: [null, ORG], resolves: true }, - { name: 'twin_lead', organizationIds: [ORG, OTHER_ORG], resolves: true }, + // `resolves` for this one is the row-only position backfill's doing, + // which C3 stage 8 unwires, so it is not asserted. + expect.objectContaining({ name: 'twin_lead', organizationIds: [ORG, OTHER_ORG] }), ]); const conflictLine = b.logger.error.mock.calls.find(([, , meta]: any[]) => meta?.conflicting); expect(conflictLine?.[2]?.conflicting?.items?.map((c: any) => c.name)).toEqual([PKG_POSITION, 'twin_lead']); }); - - it('under single, the refused-name class stays listed at every boot, after the row-only backfill has recorded its verdict', async () => { - const b = await boot(); - await insertRows(b, 'sys_position', [ - { id: 'pos_refused', name: 'Legacy Lead', label: 'Illegal name', organization_id: ORG }, - ]); - await b.bootstrapped(); - const ledger = (await b.engine.find('sys_migration', { - where: { id: 'adr-0131-position-environment-backfill' }, context: SYS, - })) as any[]; - expect(ledger.length, 'the backfill recorded its verdict: the refused-name class is final for it').toBe(1); - - await b.bootstrapped(); - expect(findings(await b.report())).toEqual([{ - organizationId: ORG, - positionAssignments: [], permissionSetGrants: [], unnamedGrants: [], - positionsWithoutDefinition: ['Legacy Lead (refused name)'], - }]); - }); }); describe('[ADR-0131 D4] a removed declaration is reported; re-adding it clears the report', () => { @@ -533,14 +516,12 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', }); describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped', () => { - it('after both backfills — a name either one resolved this boot is not reported — and the report writes nothing', async () => { + it('after the grant-name backfill — a name it resolved this boot is not reported — and the report writes nothing', async () => { const b = await boot(); - // A row-only legal position the position backfill defines this boot, and - // an unnamed grant whose set row's name the catalog resolves, which the - // grant-name backfill names this boot. Neither may be reported. - await insertRows(b, 'sys_position', [{ id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy lead', organization_id: ORG }]); + // An unnamed grant whose set row's name the catalog resolves, which the + // grant-name backfill names this boot: it may not be reported. (The + // row-only position backfill ran here too until C3 stage 8 unwired it.) await insertRows(b, 'sys_user_position', [ - { id: 'up_legacy', user_id: 'usr_a', position: 'legacy_lead', organization_id: ORG }, { id: 'up_ghost', user_id: 'usr_a', position: 'ghost_lead', organization_id: ORG }, ]); await insertRows(b, 'sys_permission_set', [{ id: 'ps_auditor', name: AUDITOR.name, label: 'Auditor' }]); @@ -549,7 +530,7 @@ describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped ]); const handlers = b.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(3); + expect(handlers.length).toBeGreaterThanOrEqual(2); for (const handler of handlers.slice(0, -1)) await handler(); const writes = [ vi.spyOn(b.engine, 'insert'), vi.spyOn(b.engine, 'update'), vi.spyOn(b.engine, 'delete'), diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts index 78bfbbc202d..2f0e5b4516e 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts @@ -301,7 +301,6 @@ describe('the claim target is the existing platform admin, by the bootstrap rule */ async function twoAdmins(file: string) { const engine = await openEngine(file); - await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access', label: 'Admin', active: true }, SYS); for (const [id, createdAt] of [ ['usr_amy', '2026-01-01T00:00:00.000Z'], ['usr_zed', '2026-06-01T00:00:00.000Z'], diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index b2c666f58b2..375c480220f 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -3,8 +3,31 @@ // (ADR-0086 D3 provenance consumed at last; ADR-0090 D5 "no ghost grants"). import { describe, it, expect } from 'vitest'; -import { cleanupPackagePermissions } from './cleanup-package-permissions'; - +import { cleanupPackagePermissions as cleanup } from './cleanup-package-permissions'; + +/** + * The sweep, with the declared-names argument C3 stage 8 adds. Spelled through + * this signature so the suite compiles against the module before and after + * that stage: before it, the sweep finds the package's sets by its catalog + * rows and ignores the names; after it, it finds them by the names. + */ +const cleanupPackagePermissions = cleanup as ( + ql: any, + packageId: string, + logger?: Parameters[2], + declaredSetNames?: readonly string[], +) => ReturnType; + +/** The permission-set names the uninstalled package declared (read off it before its withdrawal). */ +const DECLARED = ['crm_readonly', 'crm_admin']; + +/** + * A store holding both shapes a grant has carried: every grant names its set + * (ADR-0131 D4) and still carries the legacy id of the set's catalog row. The + * catalog rows themselves are here too — what a release before the row objects + * were retired (C3 stage 8) wrote — so the sweep revokes the same grants + * whether it finds the package's sets by its rows or by its declared names. + */ function makeQl() { const tables: Record = { sys_permission_set: [ @@ -17,14 +40,11 @@ function makeQl() { { id: 'ps_env', name: 'member_default', managed_by: 'user' }, { id: 'ps_legacy', name: 'admin_full_access' }, ], - sys_position_permission_set: [ - { id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_crm_ro' }, - { id: 'pps_2', position_id: 'pos_sales', permission_set_id: 'ps_crm_admin' }, - { id: 'pps_other', position_id: 'pos_sales', permission_set_id: 'ps_other' }, - ], sys_user_permission_set: [ - { id: 'ups_1', user_id: 'u1', permission_set_id: 'ps_crm_admin' }, - { id: 'ups_env', user_id: 'u1', permission_set_id: 'ps_env' }, + { id: 'ups_1', user_id: 'u1', permission_set: 'crm_admin', permission_set_id: 'ps_crm_admin' }, + { id: 'ups_2', user_id: 'u2', permission_set: 'crm_readonly', permission_set_id: 'ps_crm_ro' }, + { id: 'ups_env', user_id: 'u1', permission_set: 'member_default', permission_set_id: 'ps_env' }, + { id: 'ups_other', user_id: 'u2', permission_set: 'other_set', permission_set_id: 'ps_other' }, ], sys_audience_binding_suggestion: [ { id: 'sug_1', package_id: 'com.example.crm', permission_set_name: 'crm_readonly', anchor: 'everyone', status: 'confirmed' }, @@ -50,39 +70,41 @@ function makeQl() { } as any; } +const NOTHING = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }; + describe('cleanupPackagePermissions (#2747)', () => { - it('removes the package-owned sets, their bindings/grants, and its suggestion rows', async () => { + it('revokes every grant naming a set the package declared, and removes its suggestion rows', async () => { const ql = makeQl(); - const out = await cleanupPackagePermissions(ql, 'com.example.crm'); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); + expect(out).toMatchObject({ userGrants: 2, suggestions: 2, refused: 0 }); - // no ghost grants: nothing referencing the removed sets survives - expect(ql.tables.sys_permission_set.map((r: any) => r.id)).toEqual(['ps_other', 'ps_env', 'ps_legacy']); - expect(ql.tables.sys_position_permission_set.map((r: any) => r.id)).toEqual(['pps_other']); - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env']); + // no ghost grants: no grant naming the package's sets survives + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other']); expect(ql.tables.sys_audience_binding_suggestion.map((r: any) => r.id)).toEqual(['sug_other']); }); - it('never touches env-authored or foreign-package rows (ADR-0086 D4 provenance)', async () => { + it('never touches env-authored or foreign-package sets or their grants (ADR-0086 D4 provenance)', async () => { const ql = makeQl(); - await cleanupPackagePermissions(ql, 'com.example.crm'); + await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); const names = ql.tables.sys_permission_set.map((r: any) => r.name); expect(names).toContain('other_set'); expect(names).toContain('member_default'); expect(names).toContain('admin_full_access'); + const grants = ql.tables.sys_user_permission_set.map((r: any) => r.permission_set); + expect(grants).toEqual(['member_default', 'other_set']); }); - it('is idempotent — a second run removes nothing', async () => { + it('is idempotent — a second run revokes nothing', async () => { const ql = makeQl(); - await cleanupPackagePermissions(ql, 'com.example.crm'); - const second = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(second).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); + await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); + const second = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); + expect(second).toMatchObject({ userGrants: 0, suggestions: 0, refused: 0 }); }); it('no-ops safely on a missing package id or a non-engine handle', async () => { - expect(await cleanupPackagePermissions(makeQl(), '')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); - expect(await cleanupPackagePermissions(null, 'x')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); + expect(await cleanupPackagePermissions(makeQl(), '', undefined, DECLARED)).toEqual(NOTHING); + expect(await cleanupPackagePermissions(null, 'x', undefined, DECLARED)).toEqual(NOTHING); }); // [#12981] The swallow family. `deleteRows` absorbs a refused delete so one @@ -99,7 +121,7 @@ describe('cleanupPackagePermissions (#2747)', () => { } it('counts the refusals instead of reporting a silent zero', async () => { - const refused = await cleanupPackagePermissions(refusingQl(), 'com.example.crm'); + const refused = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', undefined, DECLARED); const nothingToDo = await cleanupPackagePermissions(makeQl(), 'com.unknown.pkg'); // Both revoked nothing. ⛔ Before the repair these two were EQUAL, and @@ -108,8 +130,8 @@ describe('cleanupPackagePermissions (#2747)', () => { expect(nothingToDo.sets + nothingToDo.positionBindings + nothingToDo.userGrants + nothingToDo.suggestions).toBe(0); expect(refused).not.toEqual(nothingToDo); - // 2 sets + 2 position bindings + 1 user grant + 2 suggestion rows. - expect(refused.refused).toBe(7); + // At least the 2 grants and the 2 suggestion rows. + expect(refused.refused).toBeGreaterThanOrEqual(4); expect(nothingToDo.refused).toBe(0); }); @@ -117,7 +139,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const warns: Array<{ msg: string; meta?: any }> = []; const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); }, - }); + }, DECLARED); // ONE line for the whole sweep, not one per refused row. expect(warns).toHaveLength(1); @@ -136,7 +158,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const out = await cleanupPackagePermissions(makeQl(), 'com.example.crm', { warn: (m: string) => { warns.push(m); }, info: (m: string) => { infos.push(m); }, - }); + }, DECLARED); expect(out.refused).toBe(0); expect(warns).toEqual([]); expect(infos).toHaveLength(1); @@ -145,8 +167,8 @@ describe('cleanupPackagePermissions (#2747)', () => { it('survives a host sink that has no warn at all', async () => { // The `?.` on `warn` is the backstop for hosts the TYPE cannot reach. // A refusal report must never become the thing that breaks an uninstall. - const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}); - expect(out.refused).toBe(7); + const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}, DECLARED); + expect(out.refused).toBeGreaterThanOrEqual(4); }); }); }); 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..9c276fd47c1 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 @@ -37,9 +37,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 58292fae7aa..010a1d6eb16 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -57,9 +57,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index f8082a3416b..b2f54e68fc7 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -84,9 +84,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index f4af169fbb8..ab25ba84650 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -74,9 +74,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index c8714ba0bc6..4aa18030a57 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -43,7 +43,7 @@ import { import { SecurityPlugin } from './security-plugin.js'; import { PermissionEvaluator } from './permission-evaluator.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { SysPermissionSet, SysPosition } from './objects/index.js'; +import { SysUserPosition, SysUserPermissionSet } from './objects/index.js'; /** The metadata-declared baseline every member resolves additively. */ const MEMBER_DEFAULT: PermissionSet = { @@ -335,8 +335,8 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject [SysUser.name]: SysUser, // better-auth, opts `edit` in [SysMember.name]: SysMember, // better-auth, write-denied blanket [SysSecret.name]: SysSecret, // private, engine-owned, named by no shipped set - [SysPosition.name]: SysPosition, // the org admin's read-only RBAC rows - [SysPermissionSet.name]: SysPermissionSet, + [SysUserPosition.name]: SysUserPosition, // the org admin's read-only RBAC rows + [SysUserPermissionSet.name]: SysUserPermissionSet, }; const shipped = (name: string): PermissionSet => { const set = defaultPermissionSets.find((ps) => ps.name === name); @@ -484,12 +484,12 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!(context)); for (const [verb, operation] of [['edit', 'update'], ['create', 'insert'], ['delete', 'delete']] as const) { - expect(evaluator.checkObjectPermission(operation, SysPosition.name, sets), `${label} ${verb}`).toBe(false); - expect(can(permissions, SysPosition.name, verb), `${label} ${verb}`).toBe(false); + expect(evaluator.checkObjectPermission(operation, SysUserPosition.name, sets), `${label} ${verb}`).toBe(false); + expect(can(permissions, SysUserPosition.name, verb), `${label} ${verb}`).toBe(false); } // …and still reads them, on both sides. - expect(evaluator.checkObjectPermission('find', SysPosition.name, sets)).toBe(true); - expect(can(permissions, SysPosition.name, 'read')).toBe(true); + expect(evaluator.checkObjectPermission('find', SysUserPosition.name, sets)).toBe(true); + expect(can(permissions, SysUserPosition.name, 'read')).toBe(true); } // `sys_user` edit: `member_default` grants it, `organization_admin` alone names it write-denied. const alone = SUBJECTS['[#20136 row b] walled org admin WITHOUT member_default']; diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index f126072ea52..0a237ad9d4a 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -30,10 +30,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { GRANT_HOLDER_MEMBERSHIP_HOOK_PACKAGE, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..e523d81bd0a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -35,10 +35,10 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { @@ -671,13 +671,12 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { await seedPrincipals(world, 'single'); await clearAllNames(world.engine); - // Three `kernel:bootstrapped` handlers, in registration order: this - // backfill, then the row-only position backfill (ADR-0131 D3, C2 stage S7), - // which `position-write-through.test.ts` drives, then the catalog reference - // report, which `catalog-reference-report.test.ts` drives. Only the first - // runs here. + // The `kernel:bootstrapped` handlers, in registration order: this backfill + // first, the catalog reference report (`catalog-reference-report.test.ts`) + // last — and between them the row-only position backfill until C3 stage 8 + // unwires it. Only the first runs here. const handlers = world.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers).toHaveLength(3); + expect(handlers.length).toBeGreaterThanOrEqual(2); const errorsBefore = world.pluginLogger.error.mock.calls.length; const warningsBefore = world.pluginLogger.warn.mock.calls.length; await handlers[0](); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..f30cfd45c0a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -45,10 +45,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index c18e01b2191..dfb7e55e27e 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -27,10 +27,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { GRANT_SET_NAME_HOOK_PACKAGE, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index b34933df9e1..54832eb7429 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -24,7 +24,7 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { SysUser, SysAccount, SysMember } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 972db17ea4c..73476bc018a 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -45,10 +45,10 @@ import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 266c03ace3f..d4612e588af 100644 --- a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -37,10 +37,10 @@ import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts index ae87e1f221b..2fca4a1fc0c 100644 --- a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts +++ b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts @@ -41,13 +41,8 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { INVITATION_PLACEMENT_SERVICE, type InvitationPlacementService } from './invitation-placement.js'; -import { - SysPermissionSet, - SysPosition, - SysPositionPermissionSet, - SysUserPermissionSet, - SysUserPosition, -} from './objects/index.js'; +import { SysUserPermissionSet, SysUserPosition } from './objects/index.js'; +import { SysPermissionSet, SysPosition, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; const SYS = { context: { isSystem: true } } as never; diff --git a/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts index 9518fd8bc0a..f6ffd20d45f 100644 --- a/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts +++ b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts @@ -17,9 +17,12 @@ import { SecurityPlugin } from './security-plugin.js'; * purpose: every enforced ceiling on an identifier is the `maxLength` of the * column that stores it (refused at the write seam by ObjectQL's * record-validator `max_length` check), and the storing columns DISAGREE — - * 100 for the three config-object name columns this plugin registers - * (`sys_permission_set.name`, `sys_position.name`, `sys_capability.name`), - * 255 for `sys_metadata.name`. A single shared `.max()` therefore cannot + * 100 for the two catalog-name columns this plugin registers + * (`sys_user_permission_set.permission_set`, `sys_user_position.position`: + * a grant and an assignment name their catalog item, ADR-0131 D4), 255 for + * `sys_metadata.name`, where the catalog definitions themselves are stored. + * [C3 stage 8] The three catalog row objects whose `name` columns this pin + * read before are retired (ADR-0131 D3). A single shared `.max()` therefore cannot * equal every consumer's enforced ceiling: `.max(100)` would newly refuse * `sys_metadata` names in (100, 255] that are legal stored rows today — * accept-set narrowing beyond enforced reality, fenced out of commit 3a04b0125's @@ -82,15 +85,18 @@ async function registeredObjects(): Promise { return captured; } -/** The three config-object name columns this plugin registers. */ -const PLUGIN_NAME_COLUMNS = ['sys_permission_set', 'sys_position', 'sys_capability'] as const; +/** The two catalog-name columns this plugin registers, as `[object, field]`. */ +const PLUGIN_NAME_COLUMNS = [ + ['sys_user_permission_set', 'permission_set'], + ['sys_user_position', 'position'], +] as const; -/** Read `.name`'s declared width, refusing to answer from absence. */ -function nameWidth(o: AnyObject | undefined, label: string): number { - const w = o?.fields?.name?.maxLength; +/** Read `.`'s declared width, refusing to answer from absence. */ +function columnWidth(o: AnyObject | undefined, label: string, field = 'name'): number { + const w = o?.fields?.[field]?.maxLength; expect( typeof w === 'number' && Number.isInteger(w) && w > 0, - `${label}.name must declare a positive integer maxLength (got ${String(w)}) — ` + + `${label}.${field} must declare a positive integer maxLength (got ${String(w)}) — ` + `without it this pin has no width to link the spec schema against`, ).toBe(true); return w as number; @@ -99,13 +105,13 @@ function nameWidth(o: AnyObject | undefined, label: string): number { /** Every measured storing column, width read off its registration surface. */ async function measuredColumns(): Promise> { const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); - const cols = PLUGIN_NAME_COLUMNS.map((n) => ({ - column: `${n}.name`, - width: nameWidth(byName.get(n), n), + const cols = PLUGIN_NAME_COLUMNS.map(([n, field]) => ({ + column: `${n}.${field}`, + width: columnWidth(byName.get(n), n, field), })); cols.push({ column: 'sys_metadata.name', - width: nameWidth(SysMetadataObject as unknown as AnyObject, 'sys_metadata'), + width: columnWidth(SysMetadataObject as unknown as AnyObject, 'sys_metadata'), }); return cols; } @@ -126,7 +132,7 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 it('enumerates a real surface and the probes really measure the schemas — the pin is not vacuous', async () => { const objects = await registeredObjects(); expect(objects.map((o) => o.name)).toEqual( - expect.arrayContaining([...PLUGIN_NAME_COLUMNS]), + expect.arrayContaining(PLUGIN_NAME_COLUMNS.map(([n]) => n)), ); // Instrument honesty: a string of the same width that VIOLATES the @@ -151,9 +157,8 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 // derivable; that is a spec accept-set change to escalate, not a value // to tidy here. const widths = new Map((await measuredColumns()).map((c) => [c.column, c.width])); - expect(widths.get('sys_permission_set.name')).toBe(100); - expect(widths.get('sys_position.name')).toBe(100); - expect(widths.get('sys_capability.name')).toBe(100); + expect(widths.get('sys_user_permission_set.permission_set')).toBe(100); + expect(widths.get('sys_user_position.position')).toBe(100); expect(widths.get('sys_metadata.name')).toBe(255); }); @@ -163,7 +168,7 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 // class would newly refuse names that are legal stored rows today — // accept-set narrowing beyond enforced reality. `sys_metadata.name` // (255) is the widest and therefore the binding case: a blanket - // `.max(100)` matching the config-object columns reds here by name. + // `.max(100)` matching the catalog-name columns reds here by name. for (const { column, width } of await measuredColumns()) { for (const [label, schema] of Object.entries(IDENTIFIER_SCHEMAS)) { expect( diff --git a/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts b/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts deleted file mode 100644 index 1eb0c143d91..00000000000 --- a/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// #11992 — the exemplar half of the carry-over ruling (commit 0e4e51b0a, recommendation A, -// maintainer 2026-08-25): the five `clone_permission_set` facet params DECLARE -// the spec's `carryOver` key, so the clone dialog's JSON facets are copied -// verbatim, shown read-only, and never offered as prefilled textareas an admin -// could hand-mangle into a clone that grants MORE than its base. -// -// ⭐ IDENTITIES, NOT COUNTS (same discipline as commit 5cb62d88b's pins one file over): -// "five params declare it" holds constant while two of them swap. Every facet -// is asserted by NAME, and the deliberate non-member (`description` — prose, -// not a permission facet) is asserted NOT to carry the key, so the boundary of -// the declaration is pinned from both sides. -// -// The SEND side is deliberately not restated here — that is -// `packaged-permission-set-lock.test.ts`'s clone-payload suite (commit 5cb62d88b's pin 6), -// which reads the params list and must stay green under this declaration -// precisely because `carryOver` changes what the dialog RENDERS, never what it -// SENDS. -import { describe, it, expect } from 'vitest'; -import { ActionParamSchema } from '@objectstack/spec/ui'; -import { SysPermissionSet } from './sys-permission-set.object.js'; - -/** The five JSON-serialized definition facets the clone carries (commit 5cb62d88b). */ -const CARRIED_FACETS = [ - 'object_permissions', - 'field_permissions', - 'system_permissions', - 'row_level_security', - 'tab_permissions', -] as const; - -const cloneParams = (): any[] => { - const action = (SysPermissionSet.actions ?? []).find( - (a: any) => a.name === 'clone_permission_set', - ); - if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions'); - return (action as any).params ?? []; -}; - -describe('clone_permission_set carry-over declaration (#11992)', () => { - it.each(CARRIED_FACETS)('%s declares carryOver: true alongside its row seed', (facet) => { - const p = cloneParams().find((x) => x.field === facet); - expect(p, `param { field: '${facet}' } is missing from clone_permission_set`).toBeDefined(); - expect(p.carryOver).toBe(true); - // The co-requirement the spec enforces at parse time — asserted here too - // so a future edit that drops the seed fails THIS suite by name instead of - // only tripping a schema refusal somewhere in a stack build. - expect(p.defaultFromRow).toBe(true); - }); - - it('description stays an ordinary editable param (no carryOver)', () => { - const p = cloneParams().find((x) => x.field === 'description'); - expect(p, 'param { field: "description" } is missing').toBeDefined(); - expect(p.carryOver).toBeUndefined(); - }); - - it('every clone param parses under ActionParamSchema (the declaration is spec-legal, not local dialect)', () => { - for (const p of cloneParams()) { - const r = ActionParamSchema.safeParse(p); - expect( - r.success, - `param ${JSON.stringify(p)} refused: ${JSON.stringify((r as { error?: unknown }).error)}`, - ).toBe(true); - } - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts index 9e112a1d3ab..b26197cb236 100644 --- a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts +++ b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts @@ -34,7 +34,6 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; import { SysUserPosition } from './sys-user-position.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; /** What each object resolved to in v16: LOCKED `system` + `userActions: {c,e,d}`. */ const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; @@ -69,7 +68,6 @@ const asV16 = (obj: { userActions?: unknown }) => ({ const OBJECTS = [ ['sys_user_position', SysUserPosition], ['sys_user_permission_set', SysUserPermissionSet], - ['sys_position_permission_set', SysPositionPermissionSet], ] as const; describe('#3355 — RBAC link tables move to `system-data` with their affordances intact', () => { 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..10ab00ec7c4 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { SysPosition, SysPermissionSet, SysCapability, SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; +import { SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; /** * RBAC object + default-permission-set assertions. Moved here with the objects @@ -62,10 +62,9 @@ describe('default permission sets', () => { it('organization_admin is read-only on global RBAC tables to prevent privilege escalation', () => { const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!; + // [C3 stage 8] The catalog row objects are retired (ADR-0131 D3); the + // assignment and grant tables are what is left to protect. for (const obj of [ - 'sys_position', - 'sys_permission_set', - 'sys_position_permission_set', 'sys_user_permission_set', 'sys_user_position', ]) { @@ -188,130 +187,6 @@ describe('default permission sets', () => { }); }); -describe('RBAC object canonical names + row actions', () => { - it('SysPosition / SysPermissionSet use their canonical sys_ short names and are system objects', () => { - expect(SysPosition.name).toBe('sys_position'); - expect(SysPermissionSet.name).toBe('sys_permission_set'); - expect(SysPosition.isSystem).toBe(true); - expect(SysPermissionSet.isSystem).toBe(true); - }); - - it('SysPosition exposes activate/deactivate/clone/set-default row actions (ADR-0090 D3 vocabulary)', () => { - const names = (SysPosition.actions ?? []).map((a) => a.name).sort(); - expect(names).toEqual(['activate_position', 'clone_position', 'deactivate_position', 'set_default_position']); - }); - - it('SysPermissionSet exposes activate/deactivate/clone row actions', () => { - const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort(); - expect(names).toEqual([ - 'activate_permission_set', 'clone_permission_set', 'deactivate_permission_set', - ]); - }); - - 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(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['admin', 'package', 'platform']); - }); - - it('[A4 #2920] SysPosition.managed_by is a select on the unified vocab and appears in every list view', () => { - const f: any = (SysPosition.fields as any).managed_by; - expect(f, 'managed_by 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(['admin', 'package', 'platform']); - // The provenance column is surfaced in all four position list views. - const views: any = SysPosition.listViews; - for (const v of ['active', 'default_positions', 'custom', 'all_positions']) { - expect(views[v].columns, `${v} view shows managed_by`).toContain('managed_by'); - } - }); - - it('[A4 #2920] all three RBAC catalogs share the identical managed_by vocabulary', () => { - const vocab = (schema: any) => - ((schema.fields.managed_by.options ?? []) as any[]).map((o) => o.value).sort(); - const cap = vocab(SysCapability); - expect(cap).toEqual(['admin', 'package', 'platform']); - expect(vocab(SysPermissionSet)).toEqual(cap); - expect(vocab(SysPosition)).toEqual(cap); - }); - - it('[ADR-0094] declares a readonly `customized` provenance flag surfaced in the All list view', () => { - const f: any = (SysPermissionSet.fields as any).customized; - expect(f, 'customized field exists').toBeDefined(); - expect(f.type).toBe('boolean'); - expect(f.readonly).toBe(true); - const allView: any = (SysPermissionSet.listViews as any).all_permsets; - expect(allView.columns).toContain('customized'); - expect(allView.columns).toContain('managed_by'); - }); - - it('[ADR-0094] locks the API name after creation (readonly on edit, editable on create)', () => { - // The name is the metadata identity the record projects from — renaming - // through the data door is rejected (400); this is the matching UI lock. - const nameField: any = (SysPermissionSet.fields as any).name; - expect(nameField.readonlyWhen, 'name carries a readonlyWhen lock').toBeTruthy(); - // The predicate keys off the server-assigned id: absent on create, present - // on edit — so create stays editable and edit is locked. - const pred = JSON.stringify(nameField.readonlyWhen); - expect(pred).toContain('record.id'); - // The static readonly flag is NOT set (that would block create too). - expect(nameField.readonly ?? false).toBe(false); - }); -}); - - -describe('sys_capability — ADR-0066 D1 capability registry', () => { - it('is a system config object with the canonical name', () => { - expect(SysCapability.name).toBe('sys_capability'); - expect(SysCapability.isSystem).toBe(true); - expect(SysCapability.managedBy).toBe('config'); - }); - - it('declares name/label/scope/managed_by fields', () => { - const f: any = SysCapability.fields; - expect(f.name).toBeDefined(); - expect(f.label).toBeDefined(); - expect(f.scope).toBeDefined(); - expect(f.managed_by).toBeDefined(); - // scope + managed_by are constrained selects - const scopeOpts = (f.scope.options ?? []).map((o: any) => o.value).sort(); - expect(scopeOpts).toEqual(['org', 'platform']); - const mbOpts = (f.managed_by.options ?? []).map((o: any) => o.value).sort(); - expect(mbOpts).toEqual(['admin', 'package', 'platform']); - }); - - it('enforces a unique index on name, scoped per organization', () => { - const nameIdx = (SysCapability.indexes ?? []).find((i: any) => Array.isArray(i.fields) && i.fields.includes('name')); - // [#8323] Was `toBe(true)`. On a DECLARED index bare `true` is the - // positional spelling of `'global'` — the listed columns verbatim — which - // made `name` an installation-wide key on a tenant-scoped object and turned - // its 409 into a cross-tenant existence oracle. The assertion is respelled - // rather than relaxed: a truthiness check here would accept the very - // spelling that was the defect. Full contract in - // `sys-capability.organization-unique.test.ts`. - expect(nameIdx?.unique).toBe('organization'); - }); -}); - describe('sys_user_permission_set — `delegated_from` is retired (#9730, ADR-0049)', () => { it('no longer declares `delegated_from` — the runtime delegation gate never read it here', () => { // Maintainer ruling 2026-08-18 (REMOVE): the delegation gate's @@ -333,31 +208,3 @@ describe('sys_user_permission_set — `delegated_from` is retired (#9730, ADR-00 expect(f.reference).toBe('sys_user'); }); }); - -describe('sys_position — `permissions` is retired (#9885, ADR-0049)', () => { - it('no longer declares `permissions` — nothing ever wrote or read it', () => { - // Maintainer ruling 2026-08-20 (REMOVE): the object-scoped census read - // every sys_position-naming file — the bootstrap writers set label / - // description / managed_by / active / is_default only, and position→grant - // resolution consults `sys_position_permission_set` rows plus the position - // `name`, never a `permissions` column. A free-text grant catalogue no - // runtime enforces tells an author that direct position-level grants - // exist; they do not. Re-declaring the key here without a runtime reader - // in the same change is the exact defect the ruling removed; this pin - // makes that re-growth loud. - expect(SysPosition.fields).not.toHaveProperty('permissions'); - }); - - it('clone_position no longer copies the retired column (defaultFromRow itself survives)', () => { - const clone: any = (SysPosition.actions ?? []).find((a: any) => a.name === 'clone_position'); - expect(clone, 'clone_position action still declared').toBeDefined(); - const copied = ((clone.params ?? []) as any[]) - .filter((p) => typeof p.field === 'string') - .map((p) => p.field); - // Positive control first: the copy mechanism is untouched — only the - // retired column left it. A clone that stopped copying `description` - // would be a different regression, not this retirement. - expect(copied).toContain('description'); - expect(copied).not.toContain('permissions'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index 1274325e039..a486114be2b 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -35,10 +35,7 @@ import { ObjectQL, VALIDATION_FAILED_CODE } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; -import { SysPosition } from './sys-position.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; -import { SysPermissionSet } from './sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; import { RESERVED_IDENTITY_NAMES, isReservedIdentityName, @@ -67,7 +64,7 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet], + objects: [SysUserPosition], } as any); await engine.syncSchemas(); engines.push(engine); @@ -130,107 +127,29 @@ describe('#15972 the reserved set is IMPORTED from the spec constant, never rety }); }); -describe('#15972 both write doors read the GENERATED list — one predicate, no second copy', () => { - const positionRule: any = (SysPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_name'); +describe('#15972 the assignment door reads the GENERATED list — one predicate, no second copy', () => { + // [C3 stage 8] The `sys_position` row object and its copy of the rule are + // retired (ADR-0131 D3): a position is a catalog definition, refused by the + // metadata door. The assignment row keeps the rule. const assignmentRule: any = (SysUserPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_position'); - it('sys_position declares the rule, at `error` severity', () => { - expect(positionRule).toBeDefined(); - // ⛔ `warning` / `info` are LOGGED and never throw — a reserved-name rule at - // either level is a rule that refuses nothing. - expect(positionRule.severity).toBe('error'); - expect(positionRule.condition.source).toContain(reservedIdentityNamesCelList()); - }); - it('sys_user_position declares the rule, at `error` severity', () => { expect(assignmentRule).toBeDefined(); + // ⛔ `warning` / `info` are LOGGED and never throw — a reserved-name rule at + // either level is a rule that refuses nothing. expect(assignmentRule.severity).toBe('error'); expect(assignmentRule.condition.source).toContain(reservedIdentityNamesCelList()); }); - it('both carry the SAME wording, parameterised only by the column', () => { - expect(positionRule.message).toBe(reservedIdentityNameMessage('name')); + it('carries the shared wording, parameterised by the column', () => { expect(assignmentRule.message).toBe(reservedIdentityNameMessage('position')); }); - it('only sys_position takes a provenance exemption — the assignment door takes none', () => { - // The asymmetry is the design: `sys_position` has a legitimate platform - // seeder for exactly these names; `sys_user_position` has none, in any - // package, so its refusal is unconditional. - expect(positionRule.condition.source).toContain('managed_by'); + it('takes no provenance exemption', () => { expect(assignmentRule.condition.source).not.toContain('managed_by'); }); }); -describe('#15972 sys_position — the object layer refuses the reserved names on a real engine', () => { - it.each([...BUILTIN_IDENTITY_NAMES])('refuses a tenant-authored position named %s', async (name) => { - const engine = await boot(); - const err = await refusalOf(() => (engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: 'Impostor' }, - { context: TENANT_CTX }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - expect(err.message).toContain('framework-reserved built-in identity name'); - }); - - it('NEGATIVE CONTROL — every other name still writes', async () => { - const engine = await boot(); - // Including the shapes a pattern-based guard would have swallowed. - for (const name of ['sales_manager', 'org_manager', 'platform_admin_deputy', 'hr_specialist']) { - await expect((engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: name }, - { context: TENANT_CTX }, - )).resolves.toBeTruthy(); - } - }); - - it('refuses a RENAME into a reserved name (the update door, not just insert)', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_position', { id: 'pos_rename', name: 'sales_manager', label: 'Sales' }, { context: TENANT_CTX }, - ); - const err = await refusalOf(() => (engine as any).update( - 'sys_position', { id: 'pos_rename', name: 'platform_admin' }, { context: TENANT_CTX }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - }); - - it('refuses a PACKAGE-provenance row too — a stack may not repurpose one either', async () => { - const engine = await boot(); - const err = await refusalOf(() => (engine as any).insert( - 'sys_position', - { id: 'pos_pkg', name: 'org_admin', label: 'Impostor', managed_by: 'package' }, - { context: { isSystem: true } }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - }); - - it("a PLATFORM-provenance row spelling a reserved name still writes — the exemption's one holder", async () => { - const engine = await boot(); - // [ADR-0131 D2] The built-in seeder that wrote these rows is retired: the - // six built-ins are declared metadata and the boot writes no row. Rows it - // wrote stay on existing databases (C7 owns their removal), stamped - // `managed_by: 'platform'` under an `isSystem` context, and the exemption - // keeps them writable — an insert of that shape, and the UPDATE leg over a - // row that already spells a reserved name, must not start refusing. - for (const name of BUILTIN_IDENTITY_NAMES) { - await (engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: name, managed_by: 'platform', active: true, is_default: false }, - { context: { isSystem: true } }, - ); - } - const rows = await (engine as any).find('sys_position', { where: {}, context: { isSystem: true } }); - const written = rows.map((r: any) => r.name); - for (const name of BUILTIN_IDENTITY_NAMES) expect(written).toContain(name); - await expect((engine as any).update( - 'sys_position', { id: 'pos_org_admin', label: 'Organization Admin' }, { context: { isSystem: true } }, - )).resolves.toBeTruthy(); - }); -}); - describe('#15972 sys_user_position — the ROW the card is about', () => { it.each([...BUILTIN_IDENTITY_NAMES])('refuses an assignment row spelling %s', async (name) => { const engine = await boot(); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts deleted file mode 100644 index 07f812be8b4..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#8535] `sys_capability.active` claims nothing about authorization — on ANY -// shipped surface, in ANY locale. -// -// The defect this pins: `deactivate_capability`'s confirmation dialog told the -// admin "Grants and resource requirements that reference it stop resolving until -// re-activated." Nothing enforced that. `PermissionEvaluator.getSystemPermissions()` -// unions `permissionSets[].systemPermissions` — plain strings — and a resource's -// `requiredPermissions` is matched against that string set; neither loads a -// `sys_capability` row. The table's only two production readers are seeders, which -// WRITE `active: true` on insert and never read it back. So an admin was told a -// withdrawal took effect and it silently did not — the escalation is what they -// believed they had prevented. -// -// The maintainer ruled (2026-08-13) that ENFORCEMENT is not the answer: putting the -// registry on the authorization hot path is an architectural change that needs its -// own designed card. The claim is withdrawn instead. -// -// ── Why this file is a SWEEP and not a pin ────────────────────────────────── -// Asserting the new wording would be the weak test: it passes the moment the new -// sentence exists, on the one surface it names, and says nothing about the five -// other places the old sentence was ALSO shipped. This walks every string on every -// surface — the object definition and all four locale bundles — and applies two -// independent checks to each: -// -// NEGATIVE the withdrawn claim's own words, per locale, appear nowhere. Catches -// a surface that was missed, and a surface added later that copies the -// old text back in. -// POSITIVE every locale's dialog and field help actually STATE the non-effect. -// Catches the cheap non-fix — deleting the false sentence and leaving -// an admin who remembers it to infer the rest — which the negative -// check alone would pass. -// -// The negative check cannot catch an arbitrary paraphrase of the falsehood in a -// language it does not read; that is why the positive check carries equal weight. -// -// If capability lifecycle ever becomes genuinely enforceable (its own card, per the -// ruling), this file is what must change with it — the wording is only true while -// `active` is unread. - -import { describe, it, expect } from 'vitest'; -import { SysCapability } from './sys-capability.object.js'; -import { enObjects } from '../translations/en.objects.generated.js'; -import { esESObjects } from '../translations/es-ES.objects.generated.js'; -import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; -import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; - -/** Every `[path, value]` string reachable in a plain-data tree. */ -function walkStrings(node: unknown, path = ''): Array<[string, string]> { - if (typeof node === 'string') return [[path, node]]; - if (Array.isArray(node)) return node.flatMap((v, i) => walkStrings(v, `${path}[${i}]`)); - if (node && typeof node === 'object') { - return Object.entries(node as Record).flatMap(([k, v]) => - walkStrings(v, path ? `${path}.${k}` : k), - ); - } - return []; -} - -/** - * The withdrawn claim, in the exact words each locale shipped it in (taken - * verbatim from the pre-fix bundles). A surface carrying any of these is - * re-asserting that deactivation withdraws access. - */ -const WITHDRAWN_CLAIM: Record = { - en: [/stop resolving/i, /until re-activated/i], - 'es-ES': [/dejar[áa]n de resolverse/i, /hasta que se reactive/i], - 'ja-JP': [/解決されなくなります/], - 'zh-CN': [/无法解析/], -}; - -/** The honest statement each locale must actually make (not merely omit the lie). */ -const NON_EFFECT_MARKER: Record = { - en: /authorization is not affected|no authorization effect/i, - 'es-ES': /no se ve afectada|no tiene ning[úu]n efecto sobre la autorizaci[óo]n/i, - 'ja-JP': /認可には(影響しません|一切影響しません)/, - 'zh-CN': /授权(不受影响|没有任何影响)/, -}; - -const BUNDLES: Array<[string, Record]> = [ - ['en', enObjects], - ['es-ES', esESObjects], - ['ja-JP', jaJPObjects], - ['zh-CN', zhCNObjects], -]; - -describe('[#8535] sys_capability deactivation claims no authorization effect', () => { - // ── NEGATIVE sweep ──────────────────────────────────────────────────────── - it('the object definition carries the withdrawn claim on no string at all', () => { - const offenders = walkStrings(SysCapability) - .filter(([, v]) => WITHDRAWN_CLAIM.en.some((re) => re.test(v))) - .map(([p, v]) => `${p}: ${v}`); - expect( - offenders, - 'a string on SysCapability states that deactivation stops grants/requirements resolving. ' + - 'Nothing enforces that (getSystemPermissions unions permission-set strings; no row is ever ' + - 'read). Either withdraw the claim, or land enforcement as its own card first.', - ).toEqual([]); - }); - - it.each(BUNDLES)('the %s bundle carries the withdrawn claim on no string at all', (locale, bundle) => { - const patterns = WITHDRAWN_CLAIM[locale]; - expect(patterns, `no withdrawn-claim patterns registered for locale ${locale}`).toBeDefined(); - const offenders = walkStrings(bundle.sys_capability, `${locale}.sys_capability`) - .filter(([, v]) => patterns.some((re) => re.test(v))) - .map(([p, v]) => `${p}: ${v}`); - expect( - offenders, - `the ${locale} bundle still ships the withdrawn claim. Editing the source object does NOT ` + - 'rewrite shipped bundles — `check-i18n-bundles.mjs --write` reports "regenerated" and leaves ' + - 'changed leaf values untouched, so this locale must be corrected by hand.', - ).toEqual([]); - }); - - // ── POSITIVE sweep ──────────────────────────────────────────────────────── - it('the source dialog states the non-effect outright', () => { - const action: any = (SysCapability.actions ?? []).find((a: any) => a.name === 'deactivate_capability'); - expect(action, 'deactivate_capability action exists').toBeDefined(); - expect( - action.confirmText, - 'the dialog must SAY authorization is unaffected, not merely stop lying about it — an admin ' + - 'who remembers the old wording has to be told it was wrong.', - ).toMatch(NON_EFFECT_MARKER.en); - }); - - it.each(BUNDLES)('the %s dialog states the non-effect outright', (locale, bundle) => { - const confirmText = bundle.sys_capability?._actions?.deactivate_capability?.confirmText; - expect(confirmText, `${locale} bundle has a deactivate_capability confirmText`).toBeTruthy(); - expect(confirmText).toMatch(NON_EFFECT_MARKER[locale]); - }); - - it('the active field documents its own inertness at the source', () => { - const active: any = (SysCapability.fields as any).active; - expect(active, 'active field exists').toBeDefined(); - expect( - active.description, - 'the field carried NO description at all, which is how the dialog became the only place its ' + - 'meaning was stated — and that statement was false.', - ).toMatch(NON_EFFECT_MARKER.en); - }); - - it.each(BUNDLES)('the %s bundle translates the active field help', (locale, bundle) => { - const help = bundle.sys_capability?.fields?.active?.help; - expect(help, `${locale} bundle has help for the active field`).toBeTruthy(); - expect( - help, - `${locale} still carries the English fill for active.help — 'os i18n extract' seeds a NEW key ` + - 'with the default-locale string, and leaving it is how a locale ships an untranslated surface.', - ).toMatch(NON_EFFECT_MARKER[locale]); - }); - - // ── Demotion from prominence (the other half of the ruling) ──────────────── - it('active is not a highlight field', () => { - expect( - SysCapability.highlightFields ?? [], - 'record-header prominence beside scope/managed_by is itself a claim that the flag belongs to ' + - 'the authorization posture — a truthful dialog under a first-class field still says it matters.', - ).not.toContain('active'); - }); - - it.each(['platform', 'org'])('the %s list view does not surface active as a column', (view) => { - const columns: string[] = ((SysCapability.listViews as any)[view]?.columns ?? []) as string[]; - expect(columns.length, `${view} view has columns`).toBeGreaterThan(0); - expect(columns).not.toContain('active'); - }); - - it('the full-catalogue view DOES still surface active', () => { - // Deliberately asserted: hiding a flag the product still lets an admin set is - // the opposite error, not a stronger fix. A catalogue attribute stays visible - // in the catalogue view. - expect((SysCapability.listViews as any).all_capabilities?.columns ?? []).toContain('active'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts deleted file mode 100644 index 34ec75602f4..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { SysCapability } from './sys-capability.object'; - -/** - * #8323 — `sys_capability.name` is unique per ORGANIZATION, not per - * installation. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured on an `isolated` deployment: an organization - * could POST a capability name and read `409` vs `201` to learn whether ANY - * other organization — or the platform seed — already held it, while its own - * `GET` on that name returned `total 0`. A refusal on a row you are not - * permitted to see is an existence oracle, and under the posture sold as - * "legal-entity / sovereignty isolation" it crosses the wall it advertises. - * - * ADR-0066 D1 is the reason per-organization is the CORRECT boundary and not - * merely the safe one: the platform DEFINES capabilities and admins EXTEND them - * in Setup (`managed_by: 'admin'`, `scope: 'org'`), so two organizations - * naming a capability the same way are not in conflict. - * - * Platform-seeded rows carry no organization and the organization key part is - * NULL-safe (`COALESCE(organization_id,'__global__')`, ADR-0120 D3), so they - * remain unique among themselves. That behaviour is pinned driver-side in - * `driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts`; - * this test pins the declaration its fixture copies. - */ -describe('sys_capability — declared uniqueness is organization-scoped (#8323)', () => { - const uniqueIndexes = (SysCapability.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect(uniqueIndexes[0].fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. - expect(uniqueIndexes[0].unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('leaves the non-unique indexes alone', () => { - // Scope discipline: #8323 changes uniqueness scope, nothing else. The - // `package_id` index is ADR-0086 D3's uninstall/upgrade query. - expect((SysCapability.indexes ?? []).map((i: any) => [i.fields, i.unique ?? false])).toEqual([ - [['name'], 'organization'], - [['scope'], false], - [['active'], false], - [['package_id'], false], - ]); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts deleted file mode 100644 index f90c819c6d2..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; -import { SysPermissionSet } from './sys-permission-set.object.js'; - -/** - * #8554 — `sys_permission_set`'s declared uniqueness is organization-scoped. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured live on a real engine BEFORE the fix, driving - * this shipped declaration through `SqlDriver` under - * `OS_TENANCY_POSTURE=isolated`: - * - * ``` - * CREATE UNIQUE INDEX uniq_sys_permission_set_name - * on sys_permission_set (name) - * - * org_jia POST name=sales_readonly → 201 - * org_yi POST the SAME → 409 UNIQUE_VIOLATION - * org_yi POST an unused → 201 ← the control that makes it an ORACLE - * org_yi GET the key → total 0 ← refused by a row it cannot see - * ``` - * - * ## Why per-organization is the CORRECT boundary, not merely the safe one - * - * Permission sets are admin-authored: the object's own header says tenants may - * add custom rows created via UI / API while the schema itself is locked — the - * same sentence that made `sys_position` a defect (#8468). Two organizations - * naming a set `sales_readonly` are not in conflict. It is the third leg of the - * ADR-0090 RBAC triad, after `sys_capability` (#8461) and `sys_position` - * (#8556), and it sits in the same directory as both. - * - * The materialized shape, the anti-vacuity twin (a SAME-organization duplicate - * must still be refused), and the migration of an installation that already - * carries the old index are pinned driver-side in - * `driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts`. This test - * pins the declaration that suite's fixture copies. - */ -describe('sys_permission_set — declared uniqueness is organization-scoped (#8554)', () => { - const uniqueIndexes = (SysPermissionSet.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect((uniqueIndexes[0] as any).fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. Asserted by EQUALITY, never - // by truthiness: a truthy check accepts the very spelling that was the bug, - // which is how #8556's equivalent pin stayed green under its own ablation. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('matches the fixture the driver suite copies, entry for entry', () => { - // The driver suite hand-copies this declaration to keep the package - // boundary (the shape #8461 and #8556 used). This assertion catches ONE - // direction of drift: - // - // caught — the shipped declaration changes and the driver fixture - // does not. This test goes red. - // NOT caught — the DRIVER fixture is edited and this declaration is not. - // Nothing compares the two copies directly; they are only - // ever checked against this third spelling. - // - // Asserted on the BUILT value, which is what a driver is handed: - // `ObjectSchema.create` normalizes an authored `{ fields: [...] }` into - // `{ fields: [...], unique: false }`. A fixture copied from the source - // text alone would be subtly wrong about what the driver sees. - expect(SysPermissionSet.indexes).toEqual([ - { fields: ['name'], unique: 'organization' }, - { fields: ['active'], unique: false }, - { fields: ['package_id'], unique: false }, - ]); - }); - - it('takes no tenancy opt-out, so organization_id is injected (the scope has a column)', () => { - // Derived from the BUILT value, not a regex over source. If this ever goes - // false the `'organization'` spelling has no column to key on and the whole - // fix is silently inert — a failure the index assertions above cannot see. - const plan = resolveInjectedSystemColumns(SysPermissionSet); - expect(SysPermissionSet.tenancy).toBeUndefined(); - expect(plan.tenant).toBe(true); - expect(plan.names.has('organization_id')).toBe(true); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts deleted file mode 100644 index 1bdeb91f623..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; -import { SysPosition } from './sys-position.object'; - -/** - * #8468 — `sys_position.name` is unique per ORGANIZATION, not per installation. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured live on a real engine before the fix, two - * organizations and the same name: - * - * ``` - * CREATE UNIQUE INDEX uniq_sys_position_name on sys_position (name) - * - * org_jia POST name=sales_manager → 201 - * org_yi POST the SAME name → 409 UNIQUE_VIOLATION - * org_yi POST an unused name → 201 - * org_yi GET that name → total 0 - * ``` - * - * A per-value refusal on a row the caller cannot read is a cross-tenant - * existence oracle; it was also a plain dead end, since the second organization - * could never name a position `sales_manager` and the 409 does not say why. - * - * ## Why per-organization is the CORRECT boundary, not merely the safe one - * - * Positions are admin-authored — `managed_by` defaults to `'admin'`, admins - * create them in Setup and via the `clone_position` action — so two - * organizations naming a position `sales_manager` are not in conflict. The - * maintainer ruling of 2026-08-13 settles the family this way. - * - * The hierarchy counter-argument weighed during triage does not apply to this - * object at all: positions are deliberately FLAT (ADR-0090 D3, finalizing - * ADR-0057 D5). `noPositionHierarchy` below pins that, because the argument for - * an installation-wide namespace would have to come from somewhere, and a - * `parent_id` appearing here later is exactly when someone would revisit it. - * - * Platform-seeded rows (`bootstrapBuiltinRoles`) carry no organization and the - * organization key part is NULL-safe (`COALESCE(organization_id,'__global__')`, - * ADR-0120 D3), so they stay unique among themselves. That behaviour, the - * materialized shape, and the migration of an installation that already carries - * the old index are pinned driver-side in - * `driver-sql/src/sql-driver-sys-position-organization-unique.test.ts`; this - * test pins the declaration that suite's fixture copies. - */ -describe('sys_position — declared uniqueness is organization-scoped (#8468)', () => { - const uniqueIndexes = (SysPosition.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect((uniqueIndexes[0] as any).fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. Asserted by equality, never - // by truthiness: a truthy check accepts the very spelling that was the bug. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('leaves the non-unique indexes alone', () => { - // Scope discipline: #8468 changes uniqueness scope, nothing else. - expect((SysPosition.indexes ?? []).map((i: any) => [i.fields, i.unique ?? false])).toEqual([ - [['name'], 'organization'], - [['active'], false], - ]); - }); - - it('matches the fixture the driver suite copies, entry for entry', () => { - // The driver suite hand-copies this declaration to keep the package - // boundary (the shape #8461 used). This assertion catches ONE direction of - // drift, and it is worth being exact about which: - // - // caught — the shipped declaration changes and the driver fixture does - // not. This test goes red. Measured: reverting the declaration - // to bare `true` leaves the entire driver suite green at 17/17, - // because that suite never imports `SysPosition`. This is the - // only thing standing between that edit and a silent pass. - // NOT caught — the DRIVER fixture is edited and this declaration is not. - // Nothing compares the literal below to `FIXED_APP`; the two - // copies are only ever checked against this third spelling. - // A driver-side edit drifts silently and this test cannot see - // it. Closing that direction means importing across the - // package boundary, which #8461 deliberately declined. - // - // So: keep both sides in step by hand, and treat the driver fixture as the - // copy that has no guard rather than assuming this pin covers it. - // - // Asserted on the BUILT value, which is what a driver is handed: - // `ObjectSchema.create` normalizes the authored `{ fields: ['active'] }` - // into `{ fields: ['active'], unique: false }`. The source spelling and the - // runtime spelling are not the same object, and a fixture copied from the - // source text alone would be subtly wrong about what the driver sees. - expect(SysPosition.indexes).toEqual([ - { fields: ['name'], unique: 'organization' }, - { fields: ['active'], unique: false }, - ]); - }); - - it('takes no tenancy opt-out, so organization_id is injected (the scope has a column)', () => { - // Derived from the built value, not a regex over source. If this ever goes - // false the `'organization'` spelling has no column to key on and the whole - // fix is inert — a silent failure the index assertions above cannot see. - const plan = resolveInjectedSystemColumns(SysPosition); - expect(SysPosition.tenancy).toBeUndefined(); - expect(plan.tenant).toBe(true); - expect(plan.names.has('organization_id')).toBe(true); - }); - - it('noPositionHierarchy: there is no parent_id, so no shared-namespace argument', () => { - // ADR-0090 D3 / ADR-0057 D5. The triage that produced the ruling treated a - // position hierarchy as the one candidate reason to want an - // installation-wide namespace; this object has none. - expect(Object.keys(SysPosition.fields)).not.toContain('parent_id'); - expect(Object.keys(SysPosition.fields)).not.toContain('parent'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index a45a8598be5..2590775ca7e 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -46,7 +46,6 @@ import { } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; @@ -59,11 +58,6 @@ interface Case { } const CASES: Case[] = [ - { - schema: SysPositionPermissionSet, - row: { position_id: 'pos_sales', permission_set_id: 'ps_crm_edit' }, - title: 'pos_sales → ps_crm_edit', - }, { schema: SysUserPermissionSet, // [ADR-0131 D4] A grant names its set; the title shows that name. diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 0cb481eb80c..f55aed9fe2e 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -51,9 +51,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index df359baec2f..5c91a12dd0e 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -77,7 +77,6 @@ import { describe, it, expect } from 'vitest'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { mapDataError, resolveThrownHttpError } from '@objectstack/types'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { classifyPackagedPermissionSet, PackagedPermissionSetLockedError, @@ -522,254 +521,10 @@ describe('pin 3 — the clone path yields an org-owned set with no upgrade linka }); }); -// ───────────────────────────────────────────────────────────────────────────── -// PIN 6 — what the CLONE ACTION SENDS: every copied facet, by identity (commit 5cb62d88b) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Pin 3 above drives the data door with a HAND-WRITTEN clone payload. That is - * precisely why it stayed green while this defect was live: it pins what the - * SERVER does with a payload, never what the ACTION DEFINITION chooses to put - * in one. `clone_permission_set` listed two of the row's six definition facets - * on its `params`, so cloning a set carrying system permissions, row-level - * security or tab permissions produced a clone with NONE of them — no error, a - * success toast, and the loss discoverable only by diffing the two records - * (commit 5cb62d88b). Fail-closed (fewer grants), and therefore quiet. - * - * It matters more since the ruling one commit above this one: the save door now - * refuses an in-place edit of a package-declared set AND its refusal names the - * clone path, so this action is the platform's own recommended remedy. - * - * So this suite reads the payload OUT OF THE ACTION instead of restating it. - * {@link clonePayload} is the clone dialog in miniature — the admin types the - * two inline params, every `defaultFromRow` param is seeded from the source row - * as objectui's `ActionParamDialog` seeds it, and `bodyExtra` rides along — so - * editing the params list is what moves this suite. That is the whole point: - * the class reopens the next time someone edits that list, unless a test is - * reading the list rather than a copy of it. - * - * ⭐ IDENTITIES, NOT COUNTS. "Five facets came across" holds constant while two - * of them swap, and asserting a facet is merely PRESENT passes on the empty - * default (`[]` / `{}`) that IS the bug. Every facet below is asserted against - * a NAMED, NON-EMPTY expected value. - */ - -/** The shipped `clone_permission_set` action — read, never restated. */ -const cloneAction = (): any => { - const action = (SysPermissionSet.actions ?? []).find((a: any) => a.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions'); - return action; -}; - -/** - * The body the clone dialog POSTs to `/api/v1/data/sys_permission_set`, - * assembled from the ACTION DEFINITION the way the dialog assembles it: a - * `defaultFromRow` param is seeded from the source row under its resolved field - * name and submitted verbatim when the admin does not touch it; an inline param - * carries what the admin typed; `bodyExtra` is merged in. - */ -function clonePayload(row: any, typed: Record): Record { - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params ?? []) { - const key = p.field ?? p.name; - if (p.defaultFromRow) { - if (row[key] !== undefined) body[key] = row[key]; - } else if (key in typed) { - body[key] = typed[key]; - } - } - return body; -} - -/** - * The base an admin clones: EVERY definition facet populated, so no assertion - * below can be satisfied by the empty default the defect produced. - * - * ⚠️ The card's repro sketch named `member_default` as "a set whose - * `system_permissions` is non-empty". Measured on this tree that is not so — - * the platform `member_default` carries a large `rowLevelSecurity` and NO - * system permissions, and `showcase_member_default` carries neither (the D7 - * lint hard-blocks system permissions on an everyone-suggested set). The defect - * is real either way; the example was not. A fixture carrying all six facets at - * once is the shape that actually measures all three of the added ones, which a - * single real set would not. - */ -const richSet = (over: Record = {}) => ({ - name: 'ops_console', - label: 'Ops Console', - description: 'Runs the operations console.', - objects: { - showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - showcase_project: { allowRead: true }, - }, - fields: { 'showcase_project.budget': { readable: true, editable: false } }, - // The three facets dropped before commit 5cb62d88b, each non-empty and each named below. - systemPermissions: ['setup.access', 'ops.export_data'], - rowLevelSecurity: [ - { - name: 'ops_own_rows', - label: 'Own Tasks Only', - description: 'Operators only select tasks assigned to them.', - object: 'showcase_task', - operation: 'select' as const, - using: 'assignee == current_user.email', - positions: ['ops'], - enabled: true, - }, - ], - tabPermissions: { app_ops: 'default_on' as const, app_admin: 'hidden' as const }, - // The RULED exclusion's positive control — see the last test. - adminScope: { - businessUnit: 'field_ops', - includeSubtree: true, - manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, - assignablePermissionSets: ['ops_console'], - }, - ...over, -}); - -/** - * The row the package door materializes for {@link richSet}. - * - * The return annotation is load-bearing, not decoration: without it tsc infers - * the object-literal type and DROPS the index signature that - * `permissionSetRowFields()` spreads in, so reading `.admin_scope` off the - * result — which the exclusion control below does directly, rather than through - * the `any[]` of `ql.permRows` — is a TS2339. It costs no precision: every - * facet column arrives through that spread already typed `any`. - */ -const richRow = (over: Record = {}): Record => ({ - id: 'ps_rich', - name: 'ops_console', - managed_by: 'package', - package_id: 'com.example.ops', - active: true, - ...permissionSetRowFields(richSet()), - ...over, -}); - -describe('pin 6 — the clone action SENDS every facet it copies, and says what it deliberately does not (#11703)', () => { - it('declares a defaultFromRow param for every copied facet — the identities, not a count', () => { - const carried = (cloneAction().params ?? []) - .filter((p: any) => p.defaultFromRow) - .map((p: any) => p.field ?? p.name) - .sort(); - - // ⭐ A COUNT here (`toHaveLength(6)`) would hold while `system_permissions` - // was swapped for something else. The set is spelled out. - expect(carried, 'the params list is what the dialog sends — these are the facets it carries').toEqual([ - 'description', - 'field_permissions', - 'object_permissions', - 'row_level_security', - 'system_permissions', - 'tab_permissions', - ]); - // `admin_scope` is the sixth definition column and is RULED out — pinned in - // its own test below so a future reader sees a decision, not an oversight. - expect(carried, 'admin_scope is excluded by ruling, not by accident').not.toContain('admin_scope'); - }); - - it('cloning a fully-populated set carries all five copied facets end to end, by name', async () => { - const ql = makeQl([richSet()]); - const protocol = makeHatchOpenProtocol(ql, { ops_console: richSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(richRow()); - const mw = makeMiddleware(ql, protocol); - - // The payload comes from the ACTION, not from this test. - const payload = clonePayload(ql.permRows[0], { - label: 'Ops Console (local)', - name: 'ops_console_local', - }); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, data: payload, - }); - - // (a) the DEFINITION that will be enforced — what `saveMetaItem` stored. - const body = protocol.saves.find((s: any) => s.name === 'ops_console_local')?.item; - expect(body, 'the clone was authored into the metadata store').toBeTruthy(); - expect(body.name).toBe('ops_console_local'); - expect(body.label, 'the admin-typed display name, not the base label').toBe('Ops Console (local)'); - expect(body.description).toBe(richSet().description); - expect(body.objects, 'object permissions').toEqual(richSet().objects); - expect(body.fields, 'field permissions').toEqual(richSet().fields); - // ⭐ The three dropped before commit 5cb62d88b. `[]` / `{}` here is the defect itself. - expect(body.systemPermissions, 'system permissions — [] here IS the #11703 silent drop').toEqual( - ['setup.access', 'ops.export_data'], - ); - expect( - (body.rowLevelSecurity ?? []).map((p: any) => p.name), - 'row-level security policies, by policy name', - ).toEqual(['ops_own_rows']); - expect(body.rowLevelSecurity, 'the RLS policy arrived whole, not as a name-only husk').toEqual( - richSet().rowLevelSecurity, - ); - expect(body.tabPermissions, 'tab permissions, per tab').toEqual({ - app_ops: 'default_on', app_admin: 'hidden', - }); - - // (b) the ROW the admin actually diffs the two records through. - const clone = ql.permRows.find((r: any) => r.name === 'ops_console_local'); - expect(clone, 'the clone record exists').toBeTruthy(); - expect(clone.managed_by, "this org's row").toBe('admin'); - expect(clone.package_id ?? null, 'no upgrade linkage').toBeNull(); - expect(JSON.parse(clone.system_permissions)).toEqual(['setup.access', 'ops.export_data']); - expect(JSON.parse(clone.row_level_security).map((p: any) => p.name)).toEqual(['ops_own_rows']); - expect(JSON.parse(clone.tab_permissions)).toEqual({ app_ops: 'default_on', app_admin: 'hidden' }); - - // The base is untouched by all of this (pin 3's invariant, re-checked on - // the richer shape — a payload that now carries five facets is a payload - // with five more chances to write to the wrong row). - const base = ql.permRows.find((r: any) => r.id === 'ps_rich'); - expect(JSON.parse(base.system_permissions), 'the package-declared base still declares its own').toEqual( - ['setup.access', 'ops.export_data'], - ); - expect(base.name).toBe('ops_console'); - }); - - it('CONTROL — admin_scope is deliberately NOT carried (ADR-0090 D12), and the dialog says so', async () => { - // ⭐ POSITIVE CONTROL FIRST. Without it "the clone has no admin_scope" is - // satisfied by a base that never had one, and this test would pin nothing. - const baseRow = richRow(); - expect(baseRow.admin_scope, 'the base carries a delegated-admin scope to lose').toBeTruthy(); - expect(JSON.parse(baseRow.admin_scope).businessUnit).toBe('field_ops'); - - // It is not on the wire… - const payload = clonePayload(baseRow, { label: 'Ops Console (local)', name: 'ops_console_local' }); - expect(Object.keys(payload), 'the clone dialog never sends admin_scope').not.toContain('admin_scope'); - - // …and it is not on the clone. - const ql = makeQl([richSet()]); - const protocol = makeHatchOpenProtocol(ql, { ops_console: richSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(baseRow); - const mw = makeMiddleware(ql, protocol); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, data: payload, - }); - - const body = protocol.saves.find((s: any) => s.name === 'ops_console_local')?.item; - expect(body, 'the clone was authored').toBeTruthy(); - expect(body.adminScope, 'a delegated-admin authority is a privilege decision, never a field copy').toBeUndefined(); - const clone = ql.permRows.find((r: any) => r.name === 'ops_console_local'); - expect(clone.admin_scope ?? null, 'and the column stays empty on the clone').toBeNull(); - - // ⭐ The exclusion has to READ as a decision to the admin standing in the - // dialog — otherwise it is the same silent drop commit 5cb62d88b fixed, merely - // ruled. The dialog's own explanatory line carries it. - const description = String(cloneAction().description ?? ''); - expect(description, 'the clone dialog states the exclusion').toMatch(/delegated-admin scope/i); - expect(description, 'and states that it is not copied').toMatch(/not copied/i); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 5 — fail-closed on ambiguity -// ───────────────────────────────────────────────────────────────────────────── +// [C3 stage 8] Pin 6 pinned the `clone_permission_set` action declared on the +// retired `sys_permission_set` object; the clone is the security catalog's +// `POST /security/permission-sets/:name/clone` door now, pinned beside it +// (`runtime/src/domains/permission-set-clone.test.ts`). describe('pin 5 — provenance that cannot be DETERMINED refuses the save', () => { it('a SchemaRegistry that throws is not the answer "not package-declared"', async () => { 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..ae2d77a7238 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -43,9 +43,9 @@ 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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts index 1cb4df84951..79503892e83 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts @@ -37,7 +37,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { permissionSetRowFields, reconcilePermissionSetProjection, diff --git a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts index 677ad6a9631..11d227bc66f 100644 --- a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts +++ b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts @@ -68,23 +68,23 @@ describe('plugin-security suggestion key bounds are their referenced columns\' ( it('the suggestion key columns carry their referenced-column bounds, not just any bound', async () => { // Pinned by VALUE, and by the RELATION that sources each value: // package_id 255 — the width every landed same-class column - // uses, `sys_permission_set.package_id` - // included, which the SAME boot pass writes - // the SAME value into. - // permission_set_name 100 — the width of `sys_permission_set.name`, the - // column this value must resolve against at - // confirm time. + // uses. + // permission_set_name 100 — the width of the other column that names a + // permission set, a grant's + // `sys_user_permission_set.permission_set` + // (ADR-0131 D4): a name confirmed here is a + // name a grant stores. [C3 stage 8] It was + // `sys_permission_set.name`, a retired object. const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); const suggestion = byName.get('sys_audience_binding_suggestion'); - const permissionSet = byName.get('sys_permission_set'); + const grant = byName.get('sys_user_permission_set'); expect(suggestion?.fields.package_id?.maxLength).toBe(255); expect(suggestion?.fields.permission_set_name?.maxLength).toBe(100); - // The referenced columns, read from the same registration surface — so a + // The referenced column, read from the same registration surface — so a // widening there turns THIS red instead of silently orphaning the bound. - expect(permissionSet?.fields.package_id?.maxLength).toBe(255); - expect(permissionSet?.fields.name?.maxLength).toBe(100); + expect(grant?.fields.permission_set?.maxLength).toBe(100); }); it('the composite key stays expressible on MySQL — utf8mb4 index-key ceiling', async () => { diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index d7451bb522c..477f53c71eb 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -33,10 +33,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { namesWithoutCatalogRow, positionNotInCatalogMessage } from './position-catalog-refusal.js'; diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts deleted file mode 100644 index 6de62903f68..00000000000 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ /dev/null @@ -1,889 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3, C2 stage S7] The `sys_position` data-door write-through under - * `single`, end to end inside one process: a real ObjectQL engine over the SQL - * driver, the real `SecurityPlugin` (so every write passes the security - * middleware the write-through is registered inside), and the REAL metadata - * door (`ObjectStackProtocolImplementation`, read from source by this package's - * vitest alias) over the same engine, writing real `sys_metadata` rows. - * - * What the pins hold, each against the door itself rather than a double of it: - * - * - a Setup create lands its row AND an environment definition that the - * security catalog read resolves; an assignment to it grants exactly what - * the same position granted as a row only; - * - an edit and a deactivation keep the row and the definition agreeing - * (`active` and `is_default` stay row-only); a rename moves the definition; - * a delete removes both; - * - a create, or a rename, into a name the metadata door refuses answers the - * door's own refusal and keeps nothing (seat re-rule Q1 = A); an edit of a - * row that already carries such a name still lands as a row write; - * - an edit that keeps a name a package or a built-in holds, and a delete of - * such a row, stand the write-through down: the write is passed on and - * nothing reaches metadata (Q2 = A), pinned at the write-through's own level - * rather than at the data door's answer; - * - a create, or a rename into, a name a package or a built-in holds answers - * the metadata door's own locked-base refusal and keeps nothing (C2 stage - * S10, ADR-0048 addendum N.2/N.3); the engine's own refusals keep theirs; - * - a package registering a name a Setup position holds in the environment - * ledger is refused `NAMESPACE_CONFLICT` (Q3 = A, within Q4 = A); - * - controls: a walled posture, a system write and a kernel without a metadata - * door write rows exactly as before. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataCommitObject, - SysMetadataAuditObject, -} from '@objectstack/metadata-core'; -import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { mapDataError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; -import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; -import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; -import { SysMigration } from '@objectstack/platform-objects/system'; - -import { SecurityPlugin } from './security-plugin.js'; -import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { createPositionWriteThrough } from './position-write-through.js'; -import { - POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID, - backfillRowOnlyPositions, - isRecordablePositionBackfillVerdict, - runOneTimePositionEnvironmentBackfill, -} from './position-environment-backfill.js'; - -const SYS = { isSystem: true } as const; -const POSTURE_ENV = 'OS_TENANCY_POSTURE'; -const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; -const ORG = 'org_pw'; -/** A package that declares one position, the way a stack's `positions` reach the registry. */ -const PKG = 'com.example.pw'; -const PKG_POSITION = 'pw_field_lead'; - -/** The non-system administrator whose writes are the data door's (superuser wildcard). */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - delete process.env[POSTURE_ENV]; - delete process.env[OWNER_ENV]; - resetPlatformAdminEmailMemo(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -interface Booted { - engine: ObjectQL; - protocol: any; - logger: { info: any; warn: any; error: any; debug: any }; - /** The data door's writer: the administrator, in the organization. */ - admin: Record; - catalogResolves(name: string): Promise; - envRows(name: string): Promise }>>; - rows(name: string): Promise; - /** Kernel lifecycle hooks the plugin registered, by event — collected, fired only by a test. */ - hooks: Map Promise>>; - catalog: ReturnType; -} - -async function boot(opts: { posture?: 'single' | 'isolated'; door?: boolean; ledger?: boolean } = {}): Promise { - const posture = opts.posture ?? 'single'; - const walled = posture !== 'single'; - if (walled) { - process.env[POSTURE_ENV] = posture; - process.env[OWNER_ENV] = 'admin@pw.example'; - } - resetPlatformAdminEmailMemo(); - - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.position-write-through', - name: 'Position write-through', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysUser, SysAccount, SysMember, SysOrganization, - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, - ...(opts.ledger === false ? [] : [SysMigration]), - ], - } as any); - await engine.syncSchemas(); - engines.push(engine); - - // A package-declared position: registered under its package, as the - // engine's stack collection loop registers a stack's `positions`, with the - // row the declared-position seeder writes for it (no provenance stamped). - (engine as any).registry.registerItem('position', { name: PKG_POSITION, label: 'Field lead' }, 'name', PKG); - await engine.insert('sys_position', { id: 'pos_pkg', name: PKG_POSITION, label: 'Field lead' }, { context: SYS } as any); - - const protocol = opts.door === false - ? null - : new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); - const metadata = { - get: async (type: string, name: string) => - type === 'permission' - ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async (type: string) => (type === 'position' ? [] : [...defaultPermissionSets, QA_ADMIN]), - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata, - ...(protocol ? { protocol } : {}), - ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } : {}), - }; - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; - const hooks = new Map Promise>>(); - const ctx: any = { - logger, - hook: (event: string, handler: () => Promise) => { - if (!hooks.has(event)) hooks.set(event, []); - hooks.get(event)!.push(handler); - }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - await engine.insert('sys_organization', { id: ORG, name: 'PW Org', slug: 'pw' }, { context: SYS } as any); - const catalog = createSecurityCatalogReader({ registry: (engine as any).registry, metadata }); - return { - engine, - protocol, - logger, - hooks, - catalog, - admin: { userId: 'usr_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] }, - async catalogResolves(name) { - const entry = await catalog.resolve('position', name); - return entry !== undefined && entry.name === name; - }, - async envRows(name) { - const rows = (await engine.find('sys_metadata', { where: { type: 'position', name }, context: SYS })) as any[]; - return rows.map((r) => ({ - organization_id: r.organization_id ?? null, - state: r.state, - body: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata, - })); - }, - async rows(name) { - return (await engine.find('sys_position', { where: { name }, context: SYS })) as any[]; - }, - }; -} - - -/** The ADR-0112 envelope a refusal carries: its code and status. */ -const envelope = (e: any) => ({ code: e?.code, status: e?.status ?? e?.httpStatus }); -/** The refusal a write answered, or `null` when it was accepted. */ -const refusalOf = (p: Promise) => p.then(() => null, (e: unknown) => e); - -const create = (b: Booted, row: Record) => - b.engine.insert('sys_position', row, { context: b.admin } as any); -const patch = (b: Booted, id: string, data: Record) => - b.engine.update('sys_position', { id, ...data }, { context: b.admin } as any); -const remove = (b: Booted, id: string) => - b.engine.delete('sys_position', { where: { id }, context: b.admin } as any); - -/** What the explain engine answers for one principal: the grants a position carries. */ -async function grantsOf(engine: ObjectQL, userId: string): Promise> { - const ctx = await buildContextForUser(engine, userId, Date.now(), ORG); - return { - positions: [...(ctx.positions ?? [])].sort(), - permissions: [...(ctx.permissions ?? [])].sort(), - systemPermissions: [...(ctx.systemPermissions ?? [])].sort(), - }; -} - -describe('[ADR-0131 D3] position write-through under single — a Setup position gets its environment definition', () => { - it('a Setup create lands its row AND an environment definition that the security catalog read resolves', async () => { - const b = await boot(); - await create(b, { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', is_default: true }); - const [row] = await b.rows('regional_lead'); - expect({ label: row.label, is_default: !!row.is_default, active: !!row.active }).toEqual({ - label: 'Regional lead', is_default: true, active: true, - }); - // `is_default` and `active` are row state: the definition carries neither. - expect(await b.envRows('regional_lead')).toEqual([{ - organization_id: null, - state: 'active', - body: { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', delegatable: false }, - }]); - expect(await b.catalogResolves('regional_lead')).toBe(true); - }); - - // [ADR-0131 D3/D4] The resolver reads a position's sets from its definition's - // `permissionSets`, never from the junction: the binding Setup makes as a - // `sys_position_permission_set` row grants nothing, with the write-through or - // without it. Setup moves onto the definition with C9 and the write-through's - // retirement; until then this is the measured gap the release note names. - it('a user assigned to a Setup-created position is granted the same with or without the write-through — and a junction binding grants nothing', async () => { - const grantsThrough = async (door: boolean) => { - const b = await boot({ door }); - await b.engine.insert('sys_user', { - id: 'usr_holder', email: 'holder@pw.example', name: 'holder', email_verified: true, - }, { context: SYS } as any); - await b.engine.insert('sys_member', { - id: 'm_holder', user_id: 'usr_holder', organization_id: ORG, role: 'member', - }, { context: SYS } as any); - await b.engine.insert('sys_permission_set', { - id: 'ps_pw_reports', - name: 'pw_reports', - label: 'Reports', - object_permissions: JSON.stringify({ sys_position: { allowRead: true } }), - system_permissions: JSON.stringify(['view_reports']), - }, { context: SYS } as any); - const created: any = await create(b, { name: 'report_reader', label: 'Report reader' }); - await b.engine.insert('sys_position_permission_set', { - id: 'pps_reports', position_id: created.id, permission_set_id: 'ps_pw_reports', - }, { context: SYS } as any); - await b.engine.insert('sys_user_position', { - id: 'up_holder', user_id: 'usr_holder', position: 'report_reader', organization_id: ORG, - }, { context: SYS } as any); - return { grants: await grantsOf(b.engine, 'usr_holder'), defined: (await b.envRows('report_reader')).length }; - }; - const before = await grantsThrough(false); // no metadata door: the row-only position, as before this stage - const after = await grantsThrough(true); - expect(before.defined).toBe(0); - expect(after.defined).toBe(1); - expect(after.grants).toEqual(before.grants); - expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']) }); - expect((after.grants as any).permissions).not.toContain('pw_reports'); - }); - - // [ADR-0131 D3/D4] `permissionSets` has no row column: a Setup edit of the - // row saves the definition again, and must carry the binding the metadata - // door declared rather than clear it. - it('a row edit keeps the `permissionSets` the stored definition names, and a rename carries them to the new name', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); - await (b.protocol as any).saveMetaItem({ - type: 'position', name: 'night_lead', item: { name: 'night_lead', label: 'Night lead', permissionSets: ['night_tools'] }, - }); - expect((await b.envRows('night_lead')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); - await patch(b, created.id, { label: 'Night supervisor' }); - expect((await b.envRows('night_lead')).map((r) => r.body)).toEqual([ - { name: 'night_lead', label: 'Night supervisor', delegatable: false, permissionSets: ['night_tools'] }, - ]); - await patch(b, created.id, { name: 'night_supervisor' }); - expect((await b.envRows('night_supervisor')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); - }); - - it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'shift_lead', label: 'Shift lead' }); - await patch(b, created.id, { label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, - ]); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await patch(b, created.id, { active: false }); - await patch(b, created.id, { is_default: true }); - expect(saves, 'a row-state patch writes no definition').not.toHaveBeenCalled(); - const [row] = await b.rows('shift_lead'); - expect({ label: row.label, active: !!row.active, is_default: !!row.is_default }).toEqual({ - label: 'Shift supervisor', active: false, is_default: true, - }); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, - ]); - }); - - it('a rename saves the new name\'s definition and deletes the old one', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); - await patch(b, created.id, { name: 'evening_lead' }); - expect(await b.rows('night_lead')).toEqual([]); - expect((await b.rows('evening_lead')).map((r) => r.id)).toEqual([created.id]); - expect(await b.envRows('night_lead')).toEqual([]); - expect((await b.envRows('evening_lead')).map((r) => r.body)).toEqual([ - { name: 'evening_lead', label: 'Night lead', delegatable: false }, - ]); - expect(await b.catalogResolves('night_lead')).toBe(false); - expect(await b.catalogResolves('evening_lead')).toBe(true); - }); - - it('a delete removes the row, then the definition', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'temp_lead', label: 'Temp lead' }); - expect(await b.catalogResolves('temp_lead')).toBe(true); - await remove(b, created.id); - expect(await b.rows('temp_lead')).toEqual([]); - expect(await b.envRows('temp_lead')).toEqual([]); - expect(await b.catalogResolves('temp_lead')).toBe(false); - }); - - it('a definition delete that fails is reported at error, once, naming the remedy — the row stays deleted', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'sticky_lead', label: 'Sticky lead' }); - vi.spyOn(b.protocol, 'deleteMetaItem').mockRejectedValueOnce(new Error('store unavailable')); - await remove(b, created.id); - expect(await b.rows('sticky_lead')).toEqual([]); - const errors = b.logger.error.mock.calls.filter(([m]: [string]) => String(m).includes("'sticky_lead'")); - expect(errors).toHaveLength(1); - expect(String(errors[0][0])).toContain('DELETE /api/v1/meta/position/sticky_lead'); - }); -}); - -describe('[ADR-0131 D3] Q1 = A — a name the metadata door refuses is refused with the door\'s own answer', () => { - it.each([ - ['uppercase and a space', 'Regional Lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a hyphen', 'regional-lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a leading digit', '9lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a leading underscore', '_lead', { code: 'INVALID_REQUEST', status: 400 }], - ['one character', 'r', { code: 'INVALID_METADATA', status: 422 }], - ['a dot', 'regional.lead', { code: 'INVALID_METADATA', status: 422 }], - ])('a create named with %s is refused and keeps nothing', async (_label, name, expected) => { - const b = await boot(); - const refusal = await refusalOf(create(b, { name, label: 'x' })); - expect(envelope(refusal)).toEqual(expected); - expect(await b.rows(name), 'the row write was undone').toEqual([]); - expect(await b.envRows(name)).toEqual([]); - }); - - it('a rename into such a name is refused, and the row and its definition keep the old name', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'audit_lead', label: 'Audit lead' }); - const refusal = await refusalOf(patch(b, created.id, { name: 'Audit Lead', label: 'Renamed' })); - expect(envelope(refusal)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); - const [row] = await b.rows('audit_lead'); - expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Audit lead' }); - expect(await b.rows('Audit Lead')).toEqual([]); - expect((await b.envRows('audit_lead')).map((r) => r.body)).toEqual([ - { name: 'audit_lead', label: 'Audit lead', delegatable: false }, - ]); - }); - - it('control: an edit of a row that already carries such a name lands as a row write, with no definition', async () => { - const b = await boot(); - // A row written before this stage (a system write is never translated). - await b.engine.insert('sys_position', { id: 'pos_legacy', name: 'Legacy Lead', label: 'Legacy' }, { context: SYS } as any); - await patch(b, 'pos_legacy', { label: 'Legacy (renamed label)' }); - const [row] = await b.rows('Legacy Lead'); - expect(row?.label).toBe('Legacy (renamed label)'); - expect(await b.envRows('Legacy Lead')).toEqual([]); - }); - - it('a metadata refusal of a legal name undoes the create, and the refusal is the answer', async () => { - const b = await boot(); - const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); - const refusal = await refusalOf(create(b, { name: 'store_lead', label: 'Store lead' })); - expect(refusal).toBe(refused); - expect(await b.rows('store_lead')).toEqual([]); - expect(await b.envRows('store_lead')).toEqual([]); - }); - - it('a metadata refusal of an edit restores the row the edit changed', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'desk_lead', label: 'Desk lead' }); - const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); - expect(await refusalOf(patch(b, created.id, { label: 'Desk supervisor' }))).toBe(refused); - expect((await b.rows('desk_lead'))[0]?.label).toBe('Desk lead'); - expect((await b.envRows('desk_lead')).map((r) => r.body)).toEqual([ - { name: 'desk_lead', label: 'Desk lead', delegatable: false }, - ]); - }); -}); - -describe('[ADR-0131 D3] Q2 = A — an edit that keeps, or a delete of, a name a package or a built-in holds stands the write-through down', () => { - // Pinned at the write-through's own level, never at the data door's answer: - // whether the data door admits an edit of a package-declared row is the - // system-row gate's call, decided on the row's provenance stamp, and that - // stamp is another change's. The write-through must stand down whatever the - // stamp says, because it reads the name's holder from the engine registry. - // A CREATE under such a name is no stand-down since stage S10 (below). - it('the write-through passes an edit or a delete on a package-held name to the next step, and writes nothing to metadata', async () => { - const b = await boot(); - const door = { - saveMetaItem: vi.fn(async (_request: Record) => undefined), - deleteMetaItem: vi.fn(async (_request: Record) => undefined), - // The real door's verdict, so the S10 refusal is in force for this double too. - packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'create' | 'delete' }) => - b.protocol.packagedBaseRefusal(request) as Error | null), - }; - const writeThrough = createPositionWriteThrough({ - ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, - }); - const passOn = async (opCtx: any, write: () => Promise) => { - const next = vi.fn(async () => { opCtx.result = await write(); }); - await writeThrough(opCtx, next); - return next.mock.calls.length; - }; - - // An edit of the package-declared row (the label and delegatable a Setup edit sends). - const edit = { object: 'sys_position', operation: 'update', data: { id: 'pos_pkg', label: 'Edited', delegatable: true }, context: b.admin }; - expect(await passOn(edit, () => b.engine.update('sys_position', { id: 'pos_pkg', label: 'Edited', delegatable: true }, { context: SYS } as any))) - .toBe(1); - // A delete of the package-declared row. - const del = { object: 'sys_position', operation: 'delete', options: { where: { id: 'pos_pkg' } }, context: b.admin }; - expect(await passOn(del, () => b.engine.delete('sys_position', { where: { id: 'pos_pkg' }, context: SYS } as any))).toBe(1); - - expect(door.saveMetaItem).not.toHaveBeenCalled(); - expect(door.deleteMetaItem).not.toHaveBeenCalled(); - expect(await b.envRows(PKG_POSITION)).toEqual([]); - - // Control: the same write-through, the same door, a name only Setup holds — it writes the definition. - const setupOnly = { object: 'sys_position', operation: 'insert', data: { name: 'setup_lead', label: 'Setup lead' }, context: b.admin }; - expect(await passOn(setupOnly, () => b.engine.insert('sys_position', { name: 'setup_lead', label: 'Setup lead' }, { context: SYS } as any))) - .toBe(1); - expect(door.saveMetaItem).toHaveBeenCalledTimes(1); - expect(door.saveMetaItem.mock.calls[0][0]).toMatchObject({ type: 'position', name: 'setup_lead' }); - }); - - it('control: the metadata door itself refuses an environment save over that name (NOT_OVERRIDABLE)', async () => { - const b = await boot(); - const refusal = await refusalOf( - b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'x' } }), - ); - expect(envelope(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - }); -}); - -describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create or a rename into a held name answers the door\'s refusal', () => { - /** The metadata door's own envelope for an environment save over a name a package or a built-in holds. */ - const DOOR_REFUSAL = { code: 'NOT_OVERRIDABLE', status: 403 }; - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares as an audience anchor', 'everyone'], - ['the platform declares as an audience anchor (guest)', 'guest'], - ])('a create under a name %s is refused with the door\'s envelope, and keeps no row and no definition', async (_holder, name) => { - const b = await boot(); - const before = (await b.rows(name)).map((r) => r.id); - const refusal = await refusalOf(create(b, { name, label: 'Mine' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect((await b.rows(name)).map((r) => r.id), 'the created row was undone').toEqual(before); - expect(await b.envRows(name)).toEqual([]); - }); - - it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata CREATE gets', async () => { - // [#22591] A Setup create is a create, so it is the door's answer to a - // create that is relayed: the metadata door's first-write save - // (`parentVersion: null`, `If-None-Match: *` on its REST route). - const b = await boot(); - const refusal: any = await refusalOf(create(b, { name: PKG_POSITION, label: 'Mine' })); - const door: any = await refusalOf(b.protocol.saveMetaItem({ - type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' }, parentVersion: null, - })); - expect({ code: refusal?.code, status: refusal?.status, message: refusal?.message }) - .toEqual({ code: door?.code, status: door?.status, message: door?.message }); - }); - - // [#22591] The remedy follows the act. The administrator is creating a - // position of their own (or renaming one into the name), so the refusal names - // the remedy a create has — a name no package or built-in holds, and where the - // held names are listed — never the edit remedy ("edit the source artifact and - // redeploy") of a position someone else declared. Pinned on the KIND: what the - // sentence points at, not its words. The door's edit refusal is the control. - const LISTING = 'GET /api/v1/meta/position'; - const remedyKind = (message: unknown) => { - const text = String(message); - const newName = text.includes(LISTING); - const edit = /\bredeploy\b|\bsource\b/.test(text); - return newName && edit ? 'both' : newName ? 'new-name' : edit ? 'edit' : 'none'; - }; - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares (everyone)', 'everyone'], - ])('a create under a name %s names a free name, not the edit remedy; the door\'s edit refusal keeps its own', async (_holder, name) => { - const b = await boot(); - const refusal: any = await refusalOf(create(b, { name, label: 'Mine' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(remedyKind(refusal?.message)).toBe('new-name'); - const edit: any = await refusalOf(b.protocol.saveMetaItem({ type: 'position', name, item: { name, label: 'Mine' } })); - expect(envelope(edit)).toEqual(DOOR_REFUSAL); - expect(remedyKind(edit?.message)).toBe('edit'); - }); - - it('a rename into a held name names a free name too', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'lane_lead', label: 'Lane lead' }); - const refusal: any = await refusalOf(patch(b, created.id, { name: 'guest' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(remedyKind(refusal?.message)).toBe('new-name'); - }); - - it('a bulk create carrying one held name is refused whole: no row of the write is kept', async () => { - const b = await boot(); - const refusal = await refusalOf(b.engine.insert('sys_position', [ - { name: 'bulk_lead', label: 'Bulk lead' }, - { name: PKG_POSITION, label: 'Mine' }, - ], { context: b.admin } as any)); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(await b.rows('bulk_lead')).toEqual([]); - expect(await b.envRows('bulk_lead')).toEqual([]); - expect((await b.rows(PKG_POSITION)).map((r) => r.id)).toEqual(['pos_pkg']); - }); - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares', 'guest'], - ])('a rename into a name %s is refused; the row and its definition keep the old name', async (_holder, held) => { - const b = await boot(); - const created: any = await create(b, { name: 'route_lead', label: 'Route lead' }); - const refusal = await refusalOf(patch(b, created.id, { name: held, label: 'Renamed' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - const [row] = await b.rows('route_lead'); - expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Route lead' }); - expect((await b.rows(held)).map((r) => r.id)).not.toContain(created.id); - // Before S10 the rename stood down for the new name and then deleted the old name's definition. - expect((await b.envRows('route_lead')).map((r) => r.body)).toEqual([ - { name: 'route_lead', label: 'Route lead', delegatable: false }, - ]); - expect(await b.envRows(held)).toEqual([]); - }); - - it('a verdict the door cannot reach is no admission: the create and the rename are undone, and the failure is the answer', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'yard_lead', label: 'Yard lead' }); - const unreadable = Object.assign(new Error('registry unreadable'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'packagedBaseRefusal').mockImplementation(() => { throw unreadable; }); - expect(await refusalOf(create(b, { name: 'dock_lead', label: 'Dock lead' }))).toBe(unreadable); - expect(await b.rows('dock_lead')).toEqual([]); - expect(await refusalOf(patch(b, created.id, { name: 'gate_lead' }))).toBe(unreadable); - expect((await b.rows('yard_lead')).map((r) => r.id)).toEqual([created.id]); - expect(await b.rows('gate_lead')).toEqual([]); - }); - - it('control: the engine\'s own refusals keep their answer — a reserved identity name stays VALIDATION_FAILED', async () => { - const b = await boot(); - // The platform holds `org_admin` too, but the engine's rule validator refuses - // the row before the door is asked (the REST layer answers it 400). - const refusal: any = await refusalOf(create(b, { name: 'org_admin', label: 'Mine' })); - expect({ name: refusal?.name, code: refusal?.code }).toEqual({ name: 'ValidationError', code: 'VALIDATION_FAILED' }); - expect(await b.rows('org_admin')).toEqual([]); - }); - - it('control: an edit that keeps a held name stays a row write, with no definition and no refusal', async () => { - const b = await boot(); - // A row that predates this stage: an organization's own row under the package's name. - await b.engine.insert('sys_position', { - id: 'pos_twin', name: PKG_POSITION, label: 'Twin', organization_id: ORG, - }, { context: SYS } as any); - await patch(b, 'pos_twin', { label: 'Twin (relabelled)' }); - expect((await b.rows(PKG_POSITION)).find((r) => r.id === 'pos_twin')?.label).toBe('Twin (relabelled)'); - expect(await b.envRows(PKG_POSITION)).toEqual([]); - }); - - it('control: a door that brings no locked-base verdict keeps the stand-down', async () => { - const b = await boot(); - const door = { - saveMetaItem: vi.fn(async (_request: Record) => undefined), - deleteMetaItem: vi.fn(async (_request: Record) => undefined), - }; - const writeThrough = createPositionWriteThrough({ - ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, - }); - const opCtx: any = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin }; - const next = vi.fn(async () => { opCtx.result = { id: 'pos_builtin', name: 'everyone' }; }); - await writeThrough(opCtx, next); - expect(next).toHaveBeenCalledTimes(1); - expect(door.saveMetaItem).not.toHaveBeenCalled(); - }); -}); - -describe('[ruling Q (a)] one holder per position name per deployment — a create or a rename into a name the environment catalog holds is refused', () => { - /** - * The wire envelope the REST door answers (`mapDataError`) — the one a - * second row of a name in ONE organization already answers, pinned as the - * control below. - */ - const DUPLICATE = { code: 'UNIQUE_VIOLATION', status: 409 }; - const httpEnvelope = (e: unknown) => { - const r = mapDataError(e, 'sys_position'); - return { code: (r.body as { code?: unknown }).code, status: r.status }; - }; - const OTHER_ORG = 'org_pw_other'; - /** An administrator of a SECOND organization of the same `single` deployment. */ - const otherAdmin = (b: Booted) => ({ ...b.admin, tenantId: OTHER_ORG, accessible_org_ids: [OTHER_ORG] }); - const bootTwo = async () => { - const b = await boot(); - await b.engine.insert('sys_organization', { id: OTHER_ORG, name: 'PW Other', slug: 'pw-other' }, { context: SYS } as any); - return b; - }; - - it("a second organization's create of a name the first organization's position holds is refused 409; the first definition is untouched", async () => { - const b = await bootTwo(); - await create(b, { name: 'shift_lead', label: 'Shift lead', description: 'First holder' }); - const refusal = await refusalOf(b.engine.insert('sys_position', { name: 'shift_lead', label: 'Theirs' }, { context: otherAdmin(b) } as any)); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - // One dialect: a second holder in the SAME organization answers the same. - expect(httpEnvelope(await refusalOf(create(b, { name: 'shift_lead', label: 'Again' })))).toEqual(DUPLICATE); - expect((await b.rows('shift_lead')).map((r) => r.label)).toEqual(['Shift lead']); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift lead', description: 'First holder', delegatable: false }, - ]); - }); - - it("a create of a name saved only through the metadata door is refused 409; the definition keeps its permissionSets", async () => { - const b = await boot(); - await b.protocol.saveMetaItem({ - type: 'position', name: 'door_lead', item: { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, - }); - const refusal = await refusalOf(create(b, { name: 'door_lead', label: 'Setup copy' })); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - expect(await b.rows('door_lead')).toEqual([]); - expect((await b.envRows('door_lead')).map((r) => r.body)).toEqual([ - { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, - ]); - }); - - it('a rename into a name the environment catalog holds is refused 409; the row keeps its old name', async () => { - const b = await bootTwo(); - await create(b, { name: 'dock_chief', label: 'Dock chief' }); - const mine: any = await b.engine.insert('sys_position', { name: 'yard_chief', label: 'Yard chief' }, { context: otherAdmin(b) } as any); - const refusal = await refusalOf(b.engine.update('sys_position', { id: mine.id, name: 'dock_chief' }, { context: otherAdmin(b) } as any)); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - expect((await b.rows('yard_chief')).map((r) => r.id)).toEqual([mine.id]); - expect((await b.envRows('dock_chief')).map((r) => r.body.label)).toEqual(['Dock chief']); - }); - - it('control: a name no definition holds is created, and an edit that keeps a held name is no second holder', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'free_lead', label: 'Free lead' }); - await patch(b, created.id, { label: 'Free lead (relabelled)' }); - expect((await b.envRows('free_lead')).map((r) => r.body.label)).toEqual(['Free lead (relabelled)']); - }); - - it('a catalog that cannot be read refuses the create — accepting would merge blind', async () => { - const b = await boot(); - const unreadable = Object.assign(new Error('catalog unreadable'), { code: 'AUTHZ_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn((b.engine as any).registry, 'getItem').mockImplementation(() => { throw unreadable; }); - const refusal: any = await refusalOf(create(b, { name: 'blind_lead', label: 'Blind lead' })); - expect(refusal).not.toBeNull(); - vi.restoreAllMocks(); - expect(await b.rows('blind_lead')).toEqual([]); - }); -}); - -describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => { - it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => { - const b = await boot(); - await create(b, { name: 'claims_lead', label: 'Claims lead' }); - let thrown: any = null; - try { - (b.engine as any).registry.registerItem('position', { name: 'claims_lead', label: 'Pkg' }, 'name', 'com.example.other'); - } catch (e) { thrown = e; } - expect(envelope(thrown)).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); - expect(thrown?.existingHolder).toEqual({ kind: 'environment' }); - expect(String(thrown?.message)).toContain('com.example.other'); - }); -}); - -describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it was', () => { - it('a walled posture: an organization administrator\'s create lands its organization row, and no definition', async () => { - const b = await boot({ posture: 'isolated' }); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await create(b, { name: 'plant_lead', label: 'Plant lead' }); - expect((await b.rows('plant_lead')).map((r) => r.organization_id)).toEqual([ORG]); - expect(saves).not.toHaveBeenCalled(); - expect(await b.envRows('plant_lead')).toEqual([]); - }); - - // [ADR-0131 D3, #15196 Q3 = A] The promotion path the release note names: - // under a wall an organization's Setup position is a row with no definition, - // so it grants nothing; the metadata door takes an environment definition - // under the same name, and that definition is what the catalog then serves. - it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { - const b = await boot({ posture: 'isolated' }); - await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); - expect(await b.catalogResolves('plant_auditor')).toBe(false); - await (b.protocol as any).saveMetaItem({ - type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, - }); - expect((await b.envRows('plant_auditor')).map((r) => r.body.permissionSets)).toEqual([['plant_tools']]); - expect(await b.catalogResolves('plant_auditor')).toBe(true); - }); - - it('a system write is never translated', async () => { - const b = await boot(); - await b.engine.insert('sys_position', { id: 'pos_sys', name: 'seeded_lead', label: 'Seeded' }, { context: SYS } as any); - expect((await b.rows('seeded_lead')).length).toBe(1); - expect(await b.envRows('seeded_lead')).toEqual([]); - }); - - it('a kernel with no metadata door writes the row exactly as before', async () => { - const b = await boot({ door: false }); - await create(b, { name: 'plain_lead', label: 'Plain lead' }); - expect((await b.rows('plain_lead')).length).toBe(1); - expect(await b.envRows('plain_lead')).toEqual([]); - }); - - it('a built-in position is still refused at the admin door, before anything is translated', async () => { - const b = await boot(); - await b.engine.insert('sys_position', { - id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', - }, { context: SYS } as any); - const refusal: any = await refusalOf(patch(b, 'pos_everyone', { label: 'All' })); - expect(refusal?.name).toBe('PermissionDeniedError'); - expect(await b.envRows('everyone')).toEqual([]); - }); - - it('the permission-set write-through is unchanged: a data-door set create still becomes a permission definition', async () => { - const b = await boot(); - await b.engine.insert('sys_permission_set', { name: 'pw_viewers', label: 'Viewers' }, { context: b.admin } as any); - const rows = (await b.engine.find('sys_metadata', { where: { type: 'permission', name: 'pw_viewers' }, context: SYS })) as any[]; - expect(rows.map((r) => r.organization_id ?? null)).toEqual([null]); - expect(await b.envRows('pw_viewers')).toEqual([]); - }); -}); - -describe('[ADR-0131 D3] the row-only position backfill — once, under single, remembered in sys_migration', () => { - /** Rows written before the write-through existed: system writes, as the pre-stage data door left them. */ - const seedRowOnly = async (b: Booted) => { - await b.engine.insert('sys_position', [ - { id: 'pos_a', name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true }, - { id: 'pos_b', name: 'Legacy Lead', label: 'Illegal name' }, - { id: 'pos_c', name: 'twin_lead', label: 'Twin' }, - { id: 'pos_d', name: 'twin_lead', label: 'Twin', organization_id: ORG }, - ], { context: SYS } as any); - }; - const census = async (b: Booted) => { - const names = [...new Set(((await b.engine.find('sys_position', { context: SYS })) as any[]).map((r) => r.name))]; - const rowOnly: string[] = []; - for (const n of names) if (!(await b.catalogResolves(n))) rowOnly.push(n); - return rowOnly.sort(); - }; - const runBootstrapped = async (b: Booted) => { - for (const handler of b.hooks.get('kernel:bootstrapped') ?? []) await handler(); - }; - const ledgerRows = async (b: Booted) => - (await b.engine.find(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID }, context: SYS, - })) as any[]; - - it('SecurityPlugin runs it at kernel:bootstrapped: the row-only census is empty after it, save the refused-name class', async () => { - const b = await boot(); - await seedRowOnly(b); - expect(await census(b)).toEqual(['Legacy Lead', 'legacy_lead', 'twin_lead']); - await runBootstrapped(b); - expect(await census(b), 'only the final refused-name class stays row-only').toEqual(['Legacy Lead']); - expect((await b.envRows('legacy_lead')).map((r) => ({ org: r.organization_id, body: r.body }))).toEqual([ - { org: null, body: { name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true } }, - ]); - expect(await b.envRows(PKG_POSITION), 'a package-declared position is left alone').toEqual([]); - const [ledger] = await ledgerRows(b); - expect(JSON.parse(ledger.details)).toEqual({ names: 4, rowOnly: 3, backfilled: 2, refusedName: 1 }); - const warned = b.logger.warn.mock.calls.filter(([m]: [string]) => String(m).includes('keep no environment definition')); - expect(warned).toHaveLength(1); - expect(warned[0][1]).toEqual({ count: 1, positions: { names: ['Legacy Lead'] } }); - }); - - it('a rerun changes nothing: the recorded verdict skips the pass, and a fresh pass writes nothing', async () => { - const b = await boot(); - await seedRowOnly(b); - await runBootstrapped(b); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - const again = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(again.status).toBe('already-run'); - const pass = await backfillRowOnlyPositions(b.engine as any, { catalog: b.catalog, door: b.protocol, logger: b.logger }); - expect({ rowOnly: pass.rowOnly, backfilled: pass.backfilled, refusedName: pass.refusedName }).toEqual({ - rowOnly: ['Legacy Lead'], backfilled: [], refusedName: ['Legacy Lead'], - }); - expect(saves).not.toHaveBeenCalled(); - expect(await ledgerRows(b)).toHaveLength(1); - }); - - it('rows of one name that disagree are not guessed at: reported at error, and the verdict stays unrecorded', async () => { - const b = await boot(); - await b.engine.insert('sys_position', [ - { id: 'pos_x', name: 'split_lead', label: 'Split A' }, - { id: 'pos_y', name: 'split_lead', label: 'Split B', organization_id: ORG }, - ], { context: SYS } as any); - const out = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(out.status).toBe('undecided'); - expect(out.backfill?.conflicting).toEqual(['split_lead']); - expect(await b.envRows('split_lead')).toEqual([]); - expect(await ledgerRows(b)).toEqual([]); - expect(b.logger.error.mock.calls.some(([m]: [string]) => String(m).includes('rows carrying each name disagree'))).toBe(true); - }); - - it('a definition write that does not land is loud, and the next boot retries', async () => { - const b = await boot(); - await seedRowOnly(b); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(new Error('store unavailable')); - const first = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(first.status).toBe('undecided'); - expect(first.backfill?.failed).toEqual(['legacy_lead']); - expect(await ledgerRows(b)).toEqual([]); - expect(b.logger.error).toHaveBeenCalled(); - const second = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(second.status).toBe('ran'); - expect(second.backfill?.backfilled).toEqual(['legacy_lead']); - expect(await ledgerRows(b)).toHaveLength(1); - }); - - it('a walled posture does not run it; neither does a kernel with no metadata door record a verdict', async () => { - const walled = await boot({ posture: 'isolated' }); - await seedRowOnly(walled); - expect((await runOneTimePositionEnvironmentBackfill(walled.engine as any, { - posture: 'isolated', catalog: walled.catalog, door: walled.protocol, logger: walled.logger, - })).status).toBe('not-applicable'); - expect(await walled.envRows('legacy_lead')).toEqual([]); - - const doorless = await boot({ door: false }); - await seedRowOnly(doorless); - const out = await runOneTimePositionEnvironmentBackfill(doorless.engine as any, { - posture: 'single', catalog: doorless.catalog, door: null, logger: doorless.logger, - }); - expect({ status: out.status, stopped: out.backfill?.stopped }).toEqual({ status: 'undecided', stopped: 'door-absent' }); - expect(await ledgerRows(doorless)).toEqual([]); - }); - - it('isRecordablePositionBackfillVerdict: the refused-name class is final; a failure, a conflict or a stop is not', () => { - const base = { names: 1, rowOnly: ['x'], backfilled: [], refusedName: ['X X'], conflicting: [], failed: [] }; - expect(isRecordablePositionBackfillVerdict(base)).toBe(true); - expect(isRecordablePositionBackfillVerdict({ ...base, failed: ['x'] })).toBe(false); - expect(isRecordablePositionBackfillVerdict({ ...base, conflicting: ['x'] })).toBe(false); - expect(isRecordablePositionBackfillVerdict({ ...base, stopped: 'scan-unreadable' })).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts index 16cc98e060b..e5448e8b2a4 100644 --- a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts @@ -41,10 +41,10 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysJwks, SysMember, SysUser, SysVerification } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; const CREDENTIAL_OBJECTS = ['sys_verification', 'sys_jwks'] as const; diff --git a/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts index 891f3d143d8..b4eab6b8eff 100644 --- a/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts +++ b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts @@ -27,18 +27,10 @@ import { describe, expect, it } from 'vitest'; import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; -import { SysCapability } from './objects/sys-capability.object'; -import { SysPermissionSet } from './objects/sys-permission-set.object'; -import { SysPosition } from './objects/sys-position.object'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object'; import { SysUserPosition } from './objects/sys-user-position.object'; const RBAC_OBJECTS = [ - ['sys_capability', SysCapability], - ['sys_permission_set', SysPermissionSet], - ['sys_position', SysPosition], - ['sys_position_permission_set', SysPositionPermissionSet], ['sys_user_permission_set', SysUserPermissionSet], ['sys_user_position', SysUserPosition], ] as const; 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..ee307642726 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -49,9 +49,9 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..1fa34be7225 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -39,10 +39,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts index e6443d1a865..cfe4d91217b 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -32,10 +32,10 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts index 8848913fd7a..b474bc2b944 100644 --- a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts @@ -28,10 +28,10 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysMember, SysUser } from '@objectstack/platform-objects/identity'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts index 2d22032bae6..e37af2446ad 100644 --- a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts @@ -53,10 +53,10 @@ import { SysUser, } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; /** The SCIM tables that name the platform user each row is about. */ diff --git a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts index a108cf61c27..442ceb2da42 100644 --- a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts +++ b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts @@ -34,10 +34,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; diff --git a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts index 36fd6aab3ed..e8e0d7aa2df 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts @@ -52,8 +52,6 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPermissionSet } from './objects/index.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; /** The organization the caller holds (its active organization). */ const OWN_ORG = 'org_a'; @@ -452,74 +450,7 @@ for (const [driverName, makeDriver] of DRIVERS) { expect((await b.table('qa_account'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: OTHER_ORG }); }); }); - - describe(`[#20013] the ADR-0094 permission-set data door — ${driverName}`, () => { - // The data door executes an insert/update of `sys_permission_set` ITSELF, - // through the metadata protocol, and never calls `next()`: no engine - // write, so no hook chain and no seam. Measured on the base: a platform - // administrator's insert under the `isolated` posture is admitted while - // Layer 0 walls the object (`organization_id = `). - it('⭐ a platform administrator’s permission-set insert under a walled posture is still admitted', async () => { - const engine = new ObjectQL(); - engine.registerDriver(makeDriver() as never, true); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.tenant-wall-post-hook-image.data-door', - name: 'Data door under a walled posture', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet], - } as never); - await engine.syncSchemas(); - engines.push(engine); - const saved = new Map(); - const protocol = { - registerMutationProjector() { /* not exercised here */ }, - async saveMetaItem(req: { name: string; item: unknown }) { saved.set(req.name, req.item); return { success: true }; }, - async deleteMetaItem() { return { success: true }; }, - async getMetaItemLayered(req: { name: string }) { - const body = saved.get(req.name) ?? null; - return { type: 'permission', name: req.name, code: null, overlay: body, overlayScope: null, effective: body }; - }, - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - protocol, - metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [...defaultPermissionSets], - }, - 'org-scoping': { name: 'com.objectstack.org-scoping' }, - tenancy: { posture: 'isolated' }, - }; - const ctx = { - 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]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx as never); - await plugin.start(ctx as never); - vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); - let engineWriteRan = false; - engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { - if (opCtx.object === 'sys_permission_set' && opCtx.operation === 'insert' && !opCtx.context?.isSystem) { - engineWriteRan = true; - } - await next(); - }); - - const outcome = await attempt(() => - engine.insert('sys_permission_set', { name: 'door_set', label: 'Door set' } as never, { context: ADMIN_CTX } as never)); - - expectAdmitted(outcome); - expect(engineWriteRan, 'the data door executed the write itself').toBe(false); - expect(saved.has('door_set'), 'the definition reached the metadata store').toBe(true); - }); - }); + // [C3 stage 8] The ADR-0094 permission-set data door this file also pinned + // is unwired with the retired `sys_permission_set` object (ADR-0131 D3): a + // permission set is written through the metadata door alone. } diff --git a/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts b/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts index cb5550b902a..91676532731 100644 --- a/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts +++ b/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts @@ -14,23 +14,21 @@ // NB: this package also defines sys_audience_binding_suggestion, which the // extract config deliberately does NOT translate — an engine-owned suggestion // queue with no admin UI surface, absent from both the config and the bundles, -// correctly out of scope. `sys_capability` and `sys_user_position` ARE translated -// (curated strings live in the bundles), so they must stay in the extract config; -// this guard is what caught sys_capability being dropped from that list. +// correctly out of scope. Every other object IS translated (curated strings live +// in the bundles), so it must stay in the extract config; this guard is what +// caught sys_capability being dropped from that list. import { describe, it, expect } from 'vitest'; import { enObjects } from './en.objects.generated.js'; +import { securityObjects } from '../manifest.js'; -// Objects the extract config (scripts/i18n-extract.config.ts) imports — -// keep the two lists in sync when adding/moving objects. -const OWNED_OBJECTS = new Set([ - 'sys_position', - 'sys_capability', - 'sys_permission_set', - 'sys_user_permission_set', - 'sys_position_permission_set', - 'sys_user_position', -]); +// Objects the extract config (scripts/i18n-extract.config.ts) imports: every +// object this plugin registers, less the untranslated suggestion queue (see +// above). Read from the manifest, so an object the plugin retires (the four +// catalog row objects, ADR-0131 D3) leaves this list with it. +const OWNED_OBJECTS = new Set( + securityObjects.map((o) => o.name).filter((name) => name !== 'sys_audience_binding_suggestion'), +); describe('objects translation bundle ownership (ADR-0029 D8)', () => { it('the en bundle contains no objects owned by other packages', () => { diff --git a/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts b/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts index 8b84dfc871c..661b3c29afc 100644 --- a/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts +++ b/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts @@ -33,8 +33,13 @@ import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; -/** The objects ADR-0090 P1 renamed — the exact surface the rename had to land on. */ -const RENAMED_OBJECTS = ['sys_position', 'sys_position_permission_set', 'sys_user_position']; +/** + * The objects ADR-0090 P1 renamed that still exist — the exact surface the + * rename had to land on. `sys_position` and `sys_position_permission_set` are + * retired with the catalog row objects (ADR-0131 D3), and their strings with + * them; `sys_user_position` remains. + */ +const RENAMED_OBJECTS = ['sys_user_position']; const LOCALES = [ // `\b` keeps this off `control` / `controlar`, which legitimately contain "rol". diff --git a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts index fbb46afc401..58f981cc875 100644 --- a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts @@ -267,8 +267,7 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(ql.registry.getItem('permission', name), `fixture permission set ${name} declared`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } if (who === 'control') { @@ -278,7 +277,6 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea // position expansion does not read, and an all-literal slate would then // open on the organization's administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: userId }, context: { isSystem: true } }); - await ql.insert('sys_position', { id: 'pos_atp', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_atp', user_id: userId, position: POSITION, organization_id: membership?.organization_id, }, SYS); diff --git a/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts index dc431b46b2f..a6e5d28f6f2 100644 --- a/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts @@ -149,7 +149,6 @@ describe('approval snapshot of a record with credential-class and internal field // all-literal slate would then open on the organization's // administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: approverId }, context: { isSystem: true } }); - await ql.insert('sys_position', { id: 'pos_credsnap', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_credsnap', user_id: approverId, position: POSITION, organization_id: membership?.organization_id, }, SYS); diff --git a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts index 9a8888c705e..c57fef1a7f7 100644 --- a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts @@ -194,13 +194,11 @@ describe('[#20964] approval snapshot field served masked on the data plane', () // administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: memberId }, context: { isSystem: true } }); const organization_id = membership?.organization_id; - await ql.insert('sys_position', { id: 'pos_snapmask', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_m', user_id: memberId, position: POSITION, organization_id }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_u', user_id: unmaskerId, position: POSITION, organization_id }, SYS); - const unmask = await ql.findOne('sys_permission_set', { where: { name: unmaskSet.name }, context: { isSystem: true } }); - expect(unmask?.id, 'fixture permission set seeded').toBeTruthy(); + expect(ql.registry.getItem('permission', unmaskSet.name), 'fixture permission set declared').toBeTruthy(); for (const userId of [unmaskerId, submitterId]) { - await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmask.name }, SYS); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmaskSet.name }, SYS); } const createRes = await stack.apiAs(submitterToken, 'POST', `/data/${OBJECT}`, { name: 'Item one', [KEY]: STORED }); diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index e708393c9fc..7964f32b1c5 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -250,8 +250,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED for a set that does not exist', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'no_such_set_8074' }, + object: 'sys_user', + where: { email: 'no_such_user_8074@verify.test' }, control: 'an app-declared permission set', disarmedBy: 'a set that failed to seed grants the principal nothing', }).read(); diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts deleted file mode 100644 index 0068380fded..00000000000 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#22360] A position the showcase package declares has its DEFINITION locked -// at the data door, the way the metadata door already locks it, while its ROW -// STATE stays switchable — over the real showcase composition under `single`, -// across a cold boot on one database file. -// -// ## What was broken -// -// The declared-position seeder wrote its rows with no provenance stamp, so they -// carried the object default (`managed_by: 'admin'`) and the system-row write -// gate did not protect them: a data-door label edit of `manager` answered 200, -// and the next boot wrote the declared label back over it, with no message. -// -// ## The rule now -// -// [ADR-0131 D2, stage 6a] No boot writes a `sys_position` row: the declared -// and built-in position seeders are retired, and a declared position lives in -// the registry. The rows an earlier boot wrote stay on an existing database -// (C7 removes them), stamped `managed_by: 'package'` by the seeder this file -// once pinned, and the system-row write gate still guards them: a patch -// touching only row state (`active`, `is_default` — Setup's Activate, -// Deactivate and Set as Default) passes; a definition column (`label`, -// `delegatable`, …) and a delete are refused with the gate's code. This file -// stages those rows itself ({@link stageExistingRows}), the way the retired -// seeder left them. -// -// ## What each case pins -// -// - precondition: the showcase package holds the name, the metadata door -// refuses an edit of it, and the boot wrote no position row; -// - a staged declared row: a label edit, a `delegatable` edit and a delete -// are refused with the gate's code and leave the row as it was; -// - Deactivate, Activate and Set as Default answer 200; a multi-row -// (`updateMany`) row-state patch lands, a multi-row label patch is refused; -// - after a cold boot: the boot writes nothing — the refused columns stand, -// the row-state writes persisted, a row stamped `admin` stays `admin`, a -// drifted label stays drifted, and a position saved through the metadata -// door gets no row (the catalog serves it); -// - controls: an administrator-authored position stays editable; a built-in -// row refuses even a bare `{ active }` patch. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const SHOWCASE_PACKAGE = 'com.example.showcase'; - -/** Positions the showcase package declares, with the labels it declares them with. */ -const declaredLabel = (name: string): string => { - const declared = ((showcaseStack as any).positions ?? []).find((p: any) => p?.name === name); - if (!declared?.label) throw new Error(`the showcase no longer declares a labelled position '${name}'`); - return declared.label; -}; -/** Definition edits and the delete are tried here. */ -const EDITED = 'manager'; -const DELETED = 'auditor'; -/** Row-state writes, one position each, so every write is read back on its own row. */ -const DEACTIVATED = 'contributor'; -const ACTIVATED = 'legal'; -const DEFAULTED = 'ops'; -const BULK = ['finance', 'client_liaison']; -/** The upgraded-deployment and drift simulations. */ -const UPGRADED = 'exec'; -const DRIFTED = 'field_ops_delegate'; -const ADMIN_AUTHORED = 'p22360_admin_authored'; -const ENV_AUTHORED = 'p22360_env_authored'; -const REFUSED = { status: 403, code: 'PERMISSION_DENIED' }; -/** Every declared row the cases read. */ -const STAGED = [EDITED, DELETED, DEACTIVATED, ACTIVATED, DEFAULTED, ...BULK, UPGRADED, DRIFTED]; - -describe('[#22360] a package-declared position: definition locked at the data door, row state switchable, across a cold boot (showcase)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? null, json }; - }; - const rows = async (name: string) => - ((await ql.find('sys_position', { where: { name }, limit: 2 }, SYS)) as any[]); - const one = async (name: string) => { - const found = await rows(name); - expect(found.length, `exactly one '${name}' row`).toBe(1); - return found[0]; - }; - const patch = async (name: string, body: Record) => - call('PATCH', `/data/sys_position/${(await one(name)).id}`, body); - const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); - const start = async () => { - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }; - /** Every `sys_position` row, by name, past every scope. */ - const census = async () => - ((await ql.find('sys_position', { where: {}, limit: 1000 }, SYS)) as any[]).map((r) => r.name).sort(); - let bootCensus: string[] = []; - /** - * The rows an existing database holds: what the retired seeders wrote — a - * declared position stamped `package` with its declared label, the - * `everyone` anchor stamped `platform`. - */ - const stageExistingRows = async () => { - for (const name of STAGED) { - await ql.insert('sys_position', { - id: `pos_${name}`, name, label: declaredLabel(name), managed_by: 'package', active: true, is_default: false, - }, SYS); - } - await ql.insert('sys_position', { - id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true, is_default: false, - }, SYS); - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-22360-')); - dbFile = join(dir, 'showcase.db'); - await start(); - bootCensus = await census(); - await stageExistingRows(); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses an edit of it', async () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - expect((ql.registry.getArtifactItem('position', EDITED) as any)?._packageId).toBe(SHOWCASE_PACKAGE); - const put = await call('PUT', `/meta/position/${EDITED}`, { name: EDITED, label: 'Manager (metadata door)' }); - expect(outcome(put), JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - // [ADR-0131 D2] The fresh boot wrote no position row at all. - expect(bootCensus).toEqual([]); - }); - - it('a label edit and a delegatable edit are refused with the gate\'s code, and the row is unchanged', async () => { - const before = await one(EDITED); - const label = await patch(EDITED, { label: 'Manager (edited in Setup)' }); - expect(outcome(label), JSON.stringify(label.json)).toEqual(REFUSED); - const delegatable = await patch(EDITED, { delegatable: !before.delegatable }); - expect(outcome(delegatable), JSON.stringify(delegatable.json)).toEqual(REFUSED); - const after = await one(EDITED); - expect({ label: after.label, delegatable: after.delegatable }) - .toEqual({ label: declaredLabel(EDITED), delegatable: before.delegatable }); - }); - - it('a delete is refused with the gate\'s code, and the row stays', async () => { - const row = await one(DELETED); - const deleted = await call('DELETE', `/data/sys_position/${row.id}`); - expect(outcome(deleted), JSON.stringify(deleted.json)).toEqual(REFUSED); - expect((await rows(DELETED)).map((r) => r.id)).toEqual([row.id]); - }); - - it('Deactivate, Activate and Set as Default answer 200 and land on the row', async () => { - const deactivated = await patch(DEACTIVATED, { active: false }); - expect(deactivated.status, JSON.stringify(deactivated.json)).toBe(200); - - // Activate needs a deactivated row first; only a system write can make one here - // without going through the door under test. - await ql.update('sys_position', { id: (await one(ACTIVATED)).id, active: false }, SYS); - const activated = await patch(ACTIVATED, { active: true }); - expect(activated.status, JSON.stringify(activated.json)).toBe(200); - - const defaulted = await patch(DEFAULTED, { is_default: true }); - expect(defaulted.status, JSON.stringify(defaulted.json)).toBe(200); - - expect({ - deactivated: (await one(DEACTIVATED)).active, - activated: (await one(ACTIVATED)).active, - defaulted: (await one(DEFAULTED)).is_default, - }).toEqual({ deactivated: false, activated: true, defaulted: true }); - }); - - it('a multi-row row-state patch lands, and a multi-row label patch over a package row is refused', async () => { - const ids = await Promise.all(BULK.map(async (name) => (await one(name)).id)); - const switched = await call('POST', '/data/sys_position/updateMany', { - records: ids.map((id) => ({ id, data: { active: false } })), - }); - expect(switched.status, JSON.stringify(switched.json)).toBe(200); - expect(await Promise.all(BULK.map(async (name) => (await one(name)).active))).toEqual([false, false]); - - const relabelled = await call('POST', '/data/sys_position/updateMany', { - records: [{ id: ids[0], data: { label: 'Finance (edited in Setup)' } }], - }); - const refusals = JSON.stringify(relabelled.json); - expect(refusals, `status ${relabelled.status}`).toContain('PERMISSION_DENIED'); - expect((await one(BULK[0])).label).toBe(declaredLabel(BULK[0])); - }); - - it('control: an administrator-authored position stays editable', async () => { - const created = await call('POST', '/data/sys_position', { name: ADMIN_AUTHORED, label: 'Admin authored' }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - expect((await one(ADMIN_AUTHORED)).managed_by).toBe('admin'); - const edited = await patch(ADMIN_AUTHORED, { label: 'Admin authored (edited)' }); - expect(edited.status, JSON.stringify(edited.json)).toBe(200); - expect((await one(ADMIN_AUTHORED)).label).toBe('Admin authored (edited)'); - }); - - it('control: a built-in refuses even a bare { active } patch', async () => { - const everyone = await one('everyone'); - expect(everyone.managed_by).toBe('platform'); - const switched = await patch('everyone', { active: false }); - expect(outcome(switched), JSON.stringify(switched.json)).toEqual(REFUSED); - const relabelled = await patch('everyone', { label: 'Everyone (edited)' }); - expect(outcome(relabelled), JSON.stringify(relabelled.json)).toEqual(REFUSED); - expect((await one('everyone')).active).toBe(everyone.active); - }); - - it('after a cold boot: the boot writes nothing — the refused columns stand, the row-state writes persisted, and no row is re-stamped, refreshed or added', async () => { - // The upgraded shape: a row stamped with the object default (`admin`) - // carrying a column an administrator set. - const upgraded = await one(UPGRADED); - await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); - // Drifted display text, which only a system write can reach now. - await ql.update('sys_position', { id: (await one(DRIFTED)).id, label: 'Field ops delegate (stale)' }, SYS); - // An environment-authored position: a metadata-door save. The catalog - // serves it; no boot writes its row. - const saved = await call('PUT', `/meta/position/${ENV_AUTHORED}`, { name: ENV_AUTHORED, label: 'Environment authored' }); - expect(saved.status, JSON.stringify(saved.json)).toBe(200); - const before = await census(); - - await stack!.stop(); - await start(); - - expect(await census()).toEqual(before); - const edited = await one(EDITED); - expect({ label: edited.label, managed_by: edited.managed_by }) - .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); - expect((await rows(DELETED)).length).toBe(1); - expect({ - deactivated: (await one(DEACTIVATED)).active, - activated: (await one(ACTIVATED)).active, - defaulted: (await one(DEFAULTED)).is_default, - bulk: await Promise.all(BULK.map(async (name) => (await one(name)).active)), - }).toEqual({ deactivated: false, activated: true, defaulted: true, bulk: [false, false] }); - - const kept = await one(UPGRADED); - expect({ managed_by: kept.managed_by, delegatable: kept.delegatable }).toEqual({ managed_by: 'admin', delegatable: true }); - expect((await one(DRIFTED)).label).toBe('Field ops delegate (stale)'); - expect(await rows(ENV_AUTHORED)).toEqual([]); - const served = await call('GET', `/meta/position/${ENV_AUTHORED}`); - expect(served.status, JSON.stringify(served.json)).toBe(200); - // This case boots the showcase again (`start()`), so it carries the - // budget `beforeAll` gives that same `start()`, not vitest's 5000 ms - // default. Since #22301 the showcase boots with the providers its - // `requires` names and its own `plugins`, as `os serve` boots it: the - // reboot read 2809 ms on a quiet shard and timed out at 5225 ms on a - // loaded one. - }, 300_000); - - it('control after the cold boot: an administrator-authored position keeps its unmanaged, edited row', async () => { - const admin = await one(ADMIN_AUTHORED); - expect([admin.label, admin.managed_by]).toEqual(['Admin authored (edited)', 'admin']); - }); -}); diff --git a/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts index 3d936fe5f0b..e1d37b10654 100644 --- a/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts @@ -134,7 +134,6 @@ describe('[#22738] a lookup title is served only for a target whose declared exp const memberId = await userId('lte-member@verify.test'); const org = (await ql.findOne('sys_member', { where: { user_id: memberId }, ...SYS }))?.organization_id; expect(org, 'the member belongs to an organization').toBeTruthy(); - await ql.insert('sys_position', { id: 'pos_lte', name: POSITION, label: 'Reviewer', active: true, organization_id: org }, SYS); for (const id of [await userId('admin@objectos.ai'), memberId]) { await ql.insert('sys_user_position', { user_id: id, position: POSITION, organization_id: org }, SYS); } diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index daeb76e49a9..3013895be94 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -110,17 +110,15 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); // Author the property: a set whose ONLY job is tabPermissions.hidden. - // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the - // write-through stores the definition the security catalog resolves. A - // system-context row insert has no definition and grants nothing. - const hideSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + // [ADR-0131 D3/D4] Authored through the metadata door, the security + // catalog's write path: the definition it stores is what the resolver reads. + const hideSet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/tab_probe_hide', { name: 'tab_probe_hide', label: 'Tab probe — hide showcase', - tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), + objects: {}, + tabPermissions: { showcase_app: 'hidden' }, }); - expect(hideSet.status, 'set authored through the data door').toBeLessThan(300); - const hideSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; - expect(hideSetId, 'hide set stored').toBeTruthy(); + expect(hideSet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set: 'tab_probe_hide' }, { context: SYS }); const hidden = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -129,12 +127,13 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — // most-visible wins across the principal's sets, matching hono's tabRank. - const showSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + const showSet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/tab_probe_show', { name: 'tab_probe_show', label: 'Tab probe — show showcase', - tab_permissions: JSON.stringify({ showcase_app: 'visible' }), + objects: {}, + tabPermissions: { showcase_app: 'visible' }, }); - expect(showSet.status, 'set authored through the data door').toBeLessThan(300); + expect(showSet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set: 'tab_probe_show' }, { context: SYS }); const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -156,7 +155,6 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { const everyone = await reader.resolve('position', 'everyone'); expect((everyone?.definition as any)?.permissionSets, 'everyone names member_default (bootstrap no longer refuses)') .toContain('member_default'); - expect(await ql.find('sys_position', { where: { name: 'everyone' }, context: SYS }), 'no everyone row is written').toEqual([]); }); it('deleting records is no longer a baseline right (anchor-forbidden bit removed)', async () => { @@ -174,16 +172,14 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // some wildcard happened to grant create. const memberUser = await ql.findOne('sys_user', { where: { email: 'baseline-member@verify.test' }, context: SYS }); expect(memberUser?.id, 'baseline member resolved').toBeTruthy(); - const inquirySet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + const inquirySet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/baseline_inquiry_probe', { name: 'baseline_inquiry_probe', label: 'Baseline probe — inquiry create, deliberately no delete', - object_permissions: JSON.stringify({ + objects: { showcase_inquiry: { allowRead: true, allowCreate: true }, - }), + }, }); - expect(inquirySet.status, 'set authored through the data door').toBeLessThan(300); - const inquirySetId = (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; - expect(inquirySetId, 'probe set stored').toBeTruthy(); + expect(inquirySet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert( 'sys_user_permission_set', { user_id: memberUser.id, permission_set: 'baseline_inquiry_probe' }, diff --git a/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts b/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts new file mode 100644 index 00000000000..702198ce71d --- /dev/null +++ b/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A permission set saved through the METADATA door is what the security catalog +// serves, on the real composition: a create, an edit and a delete each reach the +// by-name read the authorization resolver uses (ADR-0131 D3), and a grant naming +// the set is accepted exactly while the catalog holds it (ADR-0131 D4). +// +// ## Why this exists +// +// Until C3 stage 8 the `permission` mutation projector rode along on every save: +// it mirrored the body into a `sys_permission_set` row and, gated on that row +// write, echoed the body into the metadata service's in-memory registry. Stage +// 8 retires the row object and unwires the projector, so this pins the premise +// that ruling rested on — the metadata door's own registry write-through, and +// nothing the projector did, is what puts a saved set in the catalog. It holds +// with the projector wired and without it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { createSecurityCatalogReader } from '@objectstack/core'; +import { bootShowcase } from './showcase-boot.js'; + +const SET = 'door_reach_auditor'; +const SYS = { context: { isSystem: true } } as const; + +describe('a metadata-door permission save reaches the security catalog (ADR-0131 D3/D4)', () => { + let stack: VerifyStack; + let admin: string; + let reader: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let userId: string; + + beforeAll(async () => { + stack = await bootShowcase(); + admin = await stack.signIn(); + await stack.signUp('door-reach@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); + userId = (await ql.findOne('sys_user', { where: { email: 'door-reach@verify.test' }, ...SYS }))?.id; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + const grant = (id: string) => + stack.apiAs(admin, 'POST', '/data/sys_user_permission_set', { id, user_id: userId, permission_set: SET }); + + it('PRECONDITION: the name is held by nobody, so a grant naming it is refused', async () => { + expect(userId).toBeTruthy(); + expect(await reader.resolve('permission', SET)).toBeUndefined(); + const refused = await grant('ups_door_before'); + expect(refused.status, await refused.clone().text()).toBe(400); + }); + + it('a create: the catalog serves the saved body, and a grant naming it is accepted', async () => { + const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${SET}`, { + name: SET, label: 'Door v1', objects: { sys_user: { allowRead: true } }, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + + const entry = await reader.resolve('permission', SET); + expect(entry?.definition).toMatchObject({ name: SET, label: 'Door v1', objects: { sys_user: { allowRead: true } } }); + + const accepted = await grant('ups_door_after'); + expect(accepted.status, await accepted.clone().text()).toBeLessThan(300); + }); + + it('an edit: the catalog serves the new body, not the one first saved', async () => { + const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${SET}`, { + name: SET, label: 'Door v2', objects: {}, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + + const entry = await reader.resolve('permission', SET); + expect(entry?.definition).toMatchObject({ name: SET, label: 'Door v2' }); + expect((entry?.definition as { objects?: Record }).objects ?? {}).not.toHaveProperty('sys_user'); + }); + + it('a delete: the catalog no longer serves the name', async () => { + await ql.delete('sys_user_permission_set', { where: { id: 'ups_door_after' }, ...SYS }); + const deleted = await stack.apiAs(admin, 'DELETE', `/meta/permission/${SET}`); + expect(deleted.status, await deleted.clone().text()).toBeLessThan(300); + + expect(await reader.resolve('permission', SET)).toBeUndefined(); + const refused = await grant('ups_door_gone'); + expect(refused.status, await refused.clone().text()).toBe(400); + }); +}); diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index f2bd9f53363..ab7e476bd7f 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -39,6 +39,7 @@ interface Engine { find(object: string, options?: unknown): Promise>>; insert(object: string, data: Record, options?: unknown): Promise; delete(object: string, options?: unknown): Promise; + getObject?(object: string): unknown; } describe('the permission-set clone door, on a booted showcase', () => { @@ -68,11 +69,15 @@ describe('the permission-set clone door, on a booted showcase', () => { expect(source?.packageId, 'the source is not packaged on this boot').toBeTruthy(); expect(source?.definition.adminScope, 'the source carries no adminScope').toBeTruthy(); - // The source's catalog ROWS are removed first: the door reads the - // catalog, so the clone must land with no row to read. - const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); - for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); - expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); + // The source's catalog ROWS are removed first, where the row object is + // still registered: the door reads the catalog, so the clone must land + // with no row to read. (C3 stage 8 retires the object, and there are + // then no rows at all.) + if (ql.getObject?.('sys_permission_set')) { + const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); + for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); + expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); + } const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' }); const text = await res.clone().text(); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts deleted file mode 100644 index 72ccea5f54a..00000000000 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21789] The packaged-permission-set lock fires only for a set a code -// (artifact) package ships, judged from the row's provenance — and the read -// the console renders from reports the same judgment the write doors enforce. -// Over the real showcase composition, across a cold boot on one database file. -// -// ## What was broken -// -// The lock (`packaged-permission-set-lock.ts`, plugin-security) asked the -// engine registry "does any item of this name carry a package id?". The -// registry holds stored rows as well as artifacts, and a list read -// (`GET /meta/permission`) hydrates every env-wide `sys_metadata` row into it -// with the row's `package_id` column stamped onto the body as `_packageId`. -// So a set saved into a writable runtime package read as -// code-shipped after the first list read, and every edit of it answered -// `403 NOT_OVERRIDABLE` at both doors. The provenance that tells the two apart -// was on the same body all along: the hydrator writes `_provenance: 'org'` on -// every stored row (ADR-0010), and `isCodeArtifactBody` already reads it. -// -// The READ had its own half. The security plugin syncs an overlay-backed body -// into the metadata manager as a marked "projection echo", and the protocol's -// layered read serves that echo as the item's `code` layer. The echo carried -// no provenance, so an org's own set, a clone, and a runtime-package set all -// reported a code layer with no provenance, which is how objectui's -// permission-matrix editor reads "a code package ships this": it rendered them -// locked while the doors accepted the save. The echo now carries the lock's -// verdict (`_provenance: 'org'` exactly when the lock answers `org`). -// -// Measured on `origin/main` (088428fb) through this file's own steps before the -// fix: the runtime-package set's registry row was -// `{ _packageId: , _provenance: 'org' }` after the list read, -// then `PUT /meta/permission/?package=` and -// `PATCH /data/sys_permission_set/` both answered 403 `NOT_OVERRIDABLE`; -// all three shapes' layered reads served a `code` layer with no provenance and -// no envelope `provenance`, beside `editable: true`. -// -// ## The pins hold both directions -// -// The lock's declared population is code-shipped sets. This file pins the three -// shapes the card names as editable (the metadata door, the data door, and the -// layered read's report), and pins that a set the showcase package ships is -// still refused at both doors with the same code and status and is still -// reported code-shipped by the read. -// -// ## Why a booted stack, booted twice -// -// The lock's input has one producer, the list read: boot 1 issues it and -// asserts the runtime package was stamped onto the set's registry row BEFORE it -// asserts any edit lands — without that precondition an accepted edit would -// prove nothing. (The boot's own hydration does not stamp the package id; that -// was measured, so no cold-boot leg pretends to exercise it.) The read's input -// has a second producer: the projection echo is minted again by the boot's -// reconciliation rather than by a save, so boot 2, on the same file, reads the -// three shapes again before anything is written. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { securityObjects } from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const API_BASE = '/api/v1'; - -/** A writable runtime package, created through `POST /packages`. */ -const PKG = 'com.dogfood.lock21789'; -/** Shape 1 — a set saved into that runtime package. */ -const PKG_SET = 'lock21789_pkgset'; -/** Shape 2 — a set the org created through the data door. */ -const ORG_SET = 'lock21789_orgset'; -/** Shape 3 — a "Clone to customize" clone of a packaged set. */ -const CLONE = 'lock21789_clone'; -/** The control: a set the showcase package (`com.example.showcase`) ships. */ -const SHIPPED = 'showcase_contributor'; -const SHIPPED_PACKAGE = 'com.example.showcase'; - -interface Shape { label: string; name: string; packageQuery: string } -const SHAPES: Shape[] = [ - { label: 'runtime-package set', name: PKG_SET, packageQuery: `?package=${PKG}` }, - { label: 'org-owned set', name: ORG_SET, packageQuery: '' }, - { label: 'clone', name: CLONE, packageQuery: '' }, -]; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -/** Both doors answer this refusal as `{ error: , code }`: the code is top-level. */ -const refusalCode = (json: any): unknown => json?.code; - -describe('[#21789] the permission-set lock reads the row\'s provenance — the three org-owned shapes edit, a code-shipped set stays refused (showcase)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const registryRows = (name: string): any[] => - (ql.registry.listItems('permission') ?? []).filter((i: any) => i?.name === name); - - const layered = async (name: string): Promise => { - const res = await stack!.apiAs(token, 'GET', `/meta/permission/${name}/layers`); - expect(res.status, `layered read of ${name}`).toBe(200); - const json: any = await res.json(); - return json?.data ?? json; - }; - - /** A metadata-door save of the whole definition, through the door the console's editor uses. */ - const putDefinition = async (shape: Pick, label: string) => { - const res = await stack!.apiAs(token, 'PUT', `/meta/permission/${shape.name}${shape.packageQuery}`, { - name: shape.name, - label, - objects: { showcase_task: { allowRead: true } }, - }); - return { status: res.status, json: await res.json().catch(() => ({})) }; - }; - - /** A data-door edit of the record, the way Setup saves it. */ - const patchRecord = async (name: string, description: string) => { - const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); - expect(row?.id, `the ${name} record exists`).toBeTruthy(); - const res = await stack!.apiAs(token, 'PATCH', `/data/sys_permission_set/${row.id}`, { description }); - return { status: res.status, json: await res.json().catch(() => ({})), id: row.id as string }; - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21789-')); - dbFile = join(dir, 'showcase.db'); - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // Shape 1: a writable runtime package, and a set saved into it. - const pkg = await stack.apiAs(token, 'POST', '/packages', { - manifest: { id: PKG, name: 'Lock provenance probe', version: '0.1.0', type: 'app' }, - }); - expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); - const saved = await putDefinition({ name: PKG_SET, packageQuery: `?package=${PKG}` }, 'Runtime package set'); - expect(saved.status, JSON.stringify(saved.json)).toBe(200); - - // Shape 2: the org's own set, created through the data door. - const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { - name: ORG_SET, - label: 'Org-owned set', - object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), - }); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. - const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (clone)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const cloned = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(cloned.status, JSON.stringify(await cloned.clone().json().catch(() => ({})))).toBe(201); - - // The producer the card names: the list read a Studio page load issues. - const list = await stack.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the list read stamped the runtime package onto its set\'s registry row, as a stored row', () => { - // ⛔ Without this, an accepted edit below proves nothing: a list read - // that stopped stamping the package id would also leave the set editable. - const rows = registryRows(PKG_SET); - expect(rows.map((r) => ({ _packageId: r._packageId ?? null, _provenance: r._provenance ?? null }))) - .toEqual([{ _packageId: PKG, _provenance: 'org' }]); - }); - - for (const shape of SHAPES) { - it(`${shape.label}: the metadata door accepts an edit, and the edit lands`, async () => { - const label = `${shape.label} (edited at the metadata door)`; - const put = await putDefinition(shape, label); - expect(put.status, JSON.stringify(put.json)).toBe(200); - expect((await layered(shape.name))?.effective?.label).toBe(label); - }); - - it(`${shape.label}: the data door accepts an edit, and the edit lands`, async () => { - const description = `${shape.label} (edited at the data door)`; - const patch = await patchRecord(shape.name, description); - expect(patch.status, JSON.stringify(patch.json)).toBe(200); - const [after] = await ql.find('sys_permission_set', { where: { id: patch.id }, limit: 1 }, SYS); - expect(after?.description).toBe(description); - }); - - it(`${shape.label}: the layered read reports it editable and tenant-authored, not code-shipped`, async () => { - // The console's artifact test reads the `code` layer and the - // envelope's `provenance`; `'org'` is the value it carves out. - const env = await layered(shape.name); - expect({ editable: env?.editable, provenance: env?.provenance }).toEqual({ editable: true, provenance: 'org' }); - }); - } - - it('control: a set the showcase package ships is still refused at both doors with 403 NOT_OVERRIDABLE', async () => { - const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); - expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - const patch = await patchRecord(SHIPPED, 'customized'); - expect({ status: patch.status, code: refusalCode(patch.json) }, JSON.stringify(patch.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - }); - - it('control: the layered read still reports the shipped set as code-shipped and not editable', async () => { - const env = await layered(SHIPPED); - expect({ - editable: env?.editable, - provenance: env?.provenance, - packageId: env?.packageId, - codePackageId: env?.code?._packageId, - }).toEqual({ editable: false, provenance: 'package', packageId: SHIPPED_PACKAGE, codePackageId: SHIPPED_PACKAGE }); - }); - - describe('after a cold boot on the same database file', () => { - // The echo the read serves is minted again here by the boot's - // reconciliation, not by a save: nothing is written before the reads. - beforeAll(async () => { - await stack?.stop(); - stack = undefined; - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - it('the echo the boot mints carries the lock\'s verdict: all three shapes read editable and tenant-authored', async () => { - for (const shape of SHAPES) { - const env = await layered(shape.name); - expect({ name: shape.name, editable: env?.editable, provenance: env?.provenance }) - .toEqual({ name: shape.name, editable: true, provenance: 'org' }); - } - }); - - it('control: the shipped set is still refused at the metadata door with 403 NOT_OVERRIDABLE', async () => { - const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); - expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - }); - }); -}); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts deleted file mode 100644 index f0dba0ef8cc..00000000000 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21861] A data-door edit of a permission set updates the set's OWN -// `sys_metadata` row. For a set saved into a writable runtime package, that row -// is bound to the package, and the edit must land on it rather than beside it. -// Over the real showcase composition. -// -// ## What was broken -// -// The data door (`PATCH /api/v1/data/sys_permission_set/:id`) redirects a -// definition edit into the metadata store (`createPermissionSetWriteThrough`, -// plugin-security). Its update leg merged the patch into the stored body and -// saved it with no package. A `sys_metadata` row is keyed -// `(org, type, name, package_id)`, and a save that names no package targets the -// package-less row, so for a set whose only row was bound to a runtime package -// the save minted a SECOND, package-less active row carrying the edit, and left -// the package-bound row as it was. Two active rows for one name; the package's -// copy of the set silently stopped receiving the org's edits. -// -// Measured on `origin/main` (88a39c09) through this file's own steps before the -// fix: the runtime-package set's first data-door edit answered `200`, and the -// active rows for its name went from one, bound to the package, to two: the -// untouched package-bound row and a package-less row carrying the edit. -// -// ## The pins hold all three populations -// -// - a set saved into a writable runtime package: `200`, exactly one active row -// before and after, still bound to the package and carrying the edit. Edited -// twice, before and after the list read every Studio page load issues, -// because that read is what made the edit reachable from Setup at all (it -// used to trip the packaged-set lock until the lock read the row's -// provenance); -// - a package-less set: `200`, one package-less row before and after, carrying -// the edit — the fix must not start binding sets that have no package; -// - a set a code package ships: still `403 NOT_OVERRIDABLE`, and the edit -// mints no row — the lock is untouched. -// -// Rows are counted under both type spellings, across every scope, so a fork in -// any of them is visible. Each case counts before and after its edit. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; - -/** A writable runtime package, created through `POST /packages`. */ -const PKG = 'com.dogfood.bind21861'; -/** A set saved into that package through the metadata door. */ -const PKG_SET = 'bind21861_pkgset'; -/** A set the org created through the data door: no package. */ -const ORG_SET = 'bind21861_orgset'; -/** The control: a set the showcase package (`com.example.showcase`) ships. */ -const SHIPPED = 'showcase_contributor'; - -/** Both doors answer the lock's refusal as `{ error: , code }`: the code is top-level. */ -const refusalCode = (json: any): unknown => json?.code; - -interface StoredRow { organization_id: string | null; package_id: string | null; description: unknown } - -describe('[#21861] a data-door edit of a permission set updates its own sys_metadata row (showcase)', () => { - let prevCwd: string; - let dir: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - /** - * Every ACTIVE stored row for `name`, under either type spelling and in any - * scope, as its scope, its package binding and the description its body - * carries. - */ - const activeRows = async (name: string): Promise => { - const rows: any[] = []; - for (const type of ['permission', 'permissions']) { - rows.push(...await ql.find('sys_metadata', { where: { type, name, state: 'active' }, limit: 10 }, SYS)); - } - return rows.map((r) => { - const body = typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata; - return { - organization_id: r.organization_id ?? null, - package_id: r.package_id ?? null, - description: body?.description, - }; - }); - }; - - /** A data-door edit of the record, the way Setup saves it. */ - const patchRecord = async (name: string, description: string) => { - const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); - expect(row?.id, `the ${name} record exists`).toBeTruthy(); - const res = await stack!.apiAs(token, 'PATCH', `/data/sys_permission_set/${row.id}`, { description }); - return { status: res.status, json: await res.json().catch(() => ({})), id: row.id as string }; - }; - - /** Edit `name` through the data door and assert it landed on its one row, bound to `packageId`. */ - const editLandsOnItsOwnRow = async (name: string, packageId: string | null, description: string) => { - const before = await activeRows(name); - expect(before.map(({ organization_id, package_id }) => ({ organization_id, package_id })), 'before the edit') - .toEqual([{ organization_id: null, package_id: packageId }]); - - const patch = await patchRecord(name, description); - expect(patch.status, JSON.stringify(patch.json)).toBe(200); - - const after = await activeRows(name); - expect(after, 'after the edit: still one active row, same binding, carrying the edit') - .toEqual([{ organization_id: null, package_id: packageId, description }]); - const [record] = await ql.find('sys_permission_set', { where: { id: patch.id }, limit: 1 }, SYS); - expect(record?.description, 'the projected record follows the row').toBe(description); - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21861-')); - stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // A writable runtime package, and a set saved into it at the metadata door. - const pkg = await stack.apiAs(token, 'POST', '/packages', { - manifest: { id: PKG, name: 'Write-through binding probe', version: '0.1.0', type: 'app' }, - }); - expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); - const saved = await stack.apiAs(token, 'PUT', `/meta/permission/${PKG_SET}?package=${PKG}`, { - name: PKG_SET, - label: 'Runtime package set', - objects: { showcase_task: { allowRead: true } }, - }); - expect(saved.status, JSON.stringify(await saved.clone().json().catch(() => ({})))).toBe(200); - - // The org's own set, created through the data door. - const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { - name: ORG_SET, - label: 'Org-owned set', - object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), - }); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('runtime-package set: the data-door edit answers 200 and lands on its own package-bound row', async () => { - await editLandsOnItsOwnRow(PKG_SET, PKG, 'Edited at the data door'); - }); - - it('runtime-package set: the same holds after the list read every Studio page load issues', async () => { - const list = await stack!.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); - await editLandsOnItsOwnRow(PKG_SET, PKG, 'Edited again, after the list read'); - }); - - it('package-less set: the data-door edit still saves package-less, on its one row', async () => { - await editLandsOnItsOwnRow(ORG_SET, null, 'Org set, edited at the data door'); - }); - - it('control: a set a code package ships is still refused with 403 NOT_OVERRIDABLE, and no row is minted', async () => { - const before = await activeRows(SHIPPED); - const patch = await patchRecord(SHIPPED, 'customized'); - expect({ status: patch.status, code: refusalCode(patch.json) }, JSON.stringify(patch.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - expect(await activeRows(SHIPPED), 'the refused edit stored nothing').toEqual(before); - }); -}); diff --git a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts deleted file mode 100644 index 3061db9b4ca..00000000000 --- a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [ADR-0131 D3, C2 stage S7] Under `single`, a position created or edited in -// Setup is written through to the environment ledger, and the positions Setup -// wrote before that are backfilled into it once — over the real showcase -// composition, through the real data door and the real metadata door. -// -// ## What was missing -// -// Measured on `origin/main` before this stage: `POST /api/v1/data/sys_position` -// answered 201 and wrote a row and nothing else — no `sys_metadata` row, and the -// security catalog read (`createSecurityCatalogReader`) did not resolve the name. -// A metadata-door save of a position, conversely, got its row only at the next -// boot. A Setup position had no registry home, so the planned read switch -// (stage S8) would stop it granting. -// -// ## What the pins hold -// -// - a Setup create: 201, its row, an environment-scoped `sys_metadata` row, -// and the catalog read resolves it; a rename moves the definition; a delete -// removes both; -// - a create named outside the metadata door's grammar is refused with the -// door's own answer (400 INVALID_REQUEST, 422 INVALID_METADATA) and keeps no -// row (seat re-rule Q1 = A); -// - a package registering a name a Setup position now holds in the -// environment ledger is refused 422 NAMESPACE_CONFLICT (Q3 = A); -// - the backfill: on a deployment upgraded from before the stage (row-only -// positions, no verdict in `sys_migration`), the next boot gives each -// row-only position its definition and records the verdict; a name the -// metadata door refuses stays row-only as the final reported class; a third -// boot changes nothing. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { createSecurityCatalogReader } from '@objectstack/core'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const SYS = { context: { isSystem: true } } as const; -/** The backfill's ledger row (`position-environment-backfill.ts`). */ -const LEDGER_ID = 'adr-0131-position-environment-backfill'; - -describe('[ADR-0131 D3] Setup positions reach the environment ledger under single (showcase)', () => { - let dir: string; - let db: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? json?.error?.code ?? null, json }; - }; - const rows = async (name: string) => ql.find('sys_position', { where: { name }, limit: 10 }, SYS); - const envRows = async (name: string) => - ((await ql.find('sys_metadata', { where: { type: 'position', name }, limit: 10 }, SYS)) as any[]) - .map((r) => ({ organization_id: r.organization_id ?? null, state: r.state })); - const resolves = async (name: string) => { - const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack!.kernel.getService('metadata') }); - const entry = await reader.resolve('position', name); - return entry !== undefined && entry.name === name; - }; - const start = async () => { - stack = await bootShowcase({ databaseFile: db }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }; - - beforeAll(async () => { - dir = mkdtempSync(join(tmpdir(), 'dogfood-15196-s7-')); - db = join(dir, 'showcase.db'); - await start(); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: the boot runs the single posture', () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - }); - - it('a Setup create lands its row and an environment definition the catalog read resolves', async () => { - const created = await call('POST', '/data/sys_position', { - name: 's7_regional_lead', label: 'Regional lead', description: 'Leads a region', - }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - expect((await rows('s7_regional_lead')).length).toBe(1); - expect(await envRows('s7_regional_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await resolves('s7_regional_lead')).toBe(true); - }); - - it('a rename moves the definition, and a delete removes the row and the definition', async () => { - await call('POST', '/data/sys_position', { name: 's7_night_lead', label: 'Night lead' }); - const [row] = await rows('s7_night_lead'); - const renamed = await call('PATCH', `/data/sys_position/${row.id}`, { name: 's7_evening_lead' }); - expect(renamed.status, JSON.stringify(renamed.json)).toBe(200); - expect(await envRows('s7_night_lead')).toEqual([]); - expect(await envRows('s7_evening_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect({ old: await resolves('s7_night_lead'), neu: await resolves('s7_evening_lead') }).toEqual({ old: false, neu: true }); - - const deleted = await call('DELETE', `/data/sys_position/${row.id}`); - expect(deleted.status, JSON.stringify(deleted.json)).toBe(200); - expect(await rows('s7_evening_lead')).toEqual([]); - expect(await envRows('s7_evening_lead')).toEqual([]); - expect(await resolves('s7_evening_lead')).toBe(false); - }); - - it('Q1: a create named outside the metadata door grammar answers the door\'s refusal and keeps no row', async () => { - const spaced = await call('POST', '/data/sys_position', { name: 'S7 Lead', label: 'x' }); - expect({ status: spaced.status, code: spaced.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' }); - const dotted = await call('POST', '/data/sys_position', { name: 's7.lead', label: 'x' }); - expect({ status: dotted.status, code: dotted.code }).toEqual({ status: 422, code: 'INVALID_METADATA' }); - expect(await rows('S7 Lead')).toEqual([]); - expect(await rows('s7.lead')).toEqual([]); - }); - - it('Q3: a package registering a name a Setup position holds is refused 422 NAMESPACE_CONFLICT', async () => { - let thrown: any = null; - try { - ql.registry.registerItem('position', { name: 's7_regional_lead', label: 'Pkg' }, 'name', 'com.dogfood.s7'); - } catch (e) { thrown = e; } - expect({ code: thrown?.code, status: thrown?.status }).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); - }); - - it('the backfill: an upgraded deployment\'s row-only positions get their definitions on the next boot, once', async () => { - // The upgraded shape: rows the pre-stage data door wrote (system writes are - // never translated), and no backfill verdict in the ledger. - await ql.insert('sys_position', [ - { id: 'pos_s7_legacy', name: 's7_legacy_lead', label: 'Legacy lead' }, - { id: 'pos_s7_illegal', name: 'S7 Legacy', label: 'Illegal name' }, - ], SYS); - await ql.delete('sys_migration', { where: { id: LEDGER_ID }, ...SYS }); - expect({ legacy: await resolves('s7_legacy_lead'), illegal: await resolves('S7 Legacy') }) - .toEqual({ legacy: false, illegal: false }); - - await stack!.stop(); - await start(); - expect(await envRows('s7_legacy_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await resolves('s7_legacy_lead')).toBe(true); - expect(await resolves('S7 Legacy'), 'a name the door refuses stays row-only, reported').toBe(false); - const ledger = (await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]; - expect(ledger.length).toBe(1); - expect(JSON.parse(ledger[0].details)).toMatchObject({ backfilled: 1, refusedName: 1 }); - - await stack!.stop(); - await start(); - expect(await envRows('s7_legacy_lead'), 'a third boot writes nothing').toEqual([{ organization_id: null, state: 'active' }]); - expect(((await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]).length).toBe(1); - }, 300_000); -}); diff --git a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts b/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts deleted file mode 100644 index c39994861ec..00000000000 --- a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [ADR-0131 D3, ADR-0048 addendum N.2/N.3] Under `single`, a Setup create of a -// position — or a rename into a name — that a package or a built-in already -// holds answers the metadata door's own refusal, `403 NOT_OVERRIDABLE`, -// through the data door: over the real showcase composition, over HTTP. -// -// ## Why an HTTP pin -// -// The refusal is pinned in `plugin-security`'s unit harness -// (`position-write-through.test.ts`), against the real metadata door but -// below the HTTP layer. What a Setup page receives is what the REST layer -// makes of that refusal — the status, the code, and whether the row the -// write-through wrote first is really undone — so this file asks the -// composed runtime, as an administrator's session does. -// -// ## What the pins hold -// -// - precondition: the single posture; the showcase package holds `manager`, -// and the metadata door refuses a save of it with `403 NOT_OVERRIDABLE`; -// - `POST /api/v1/data/sys_position` under that name, and under the built-in -// audience anchor `everyone`, answers the door's status and code, and -// leaves the name's rows exactly as they were, with no environment -// definition written; -// - a rename into a held name answers the same, and the renamed row keeps -// its old name and its definition; -// - control: a create under a free name answers 201. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const SYS = { context: { isSystem: true } } as const; -/** The metadata door's envelope for a save over a name a package or a built-in holds. */ -const DOOR_REFUSAL = { status: 403, code: 'NOT_OVERRIDABLE' }; -/** A position the showcase package declares. */ -const HELD = 'manager'; -/** The built-in audience anchor. */ -const ANCHOR = 'everyone'; - -describe('[ADR-0131 D3] a Setup create or rename into a held position name answers the metadata door\'s refusal over HTTP (showcase)', () => { - let dir: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? json?.error?.code ?? null, json }; - }; - const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); - const rowIds = async (name: string) => - ((await ql.find('sys_position', { where: { name }, limit: 10 }, SYS)) as any[]).map((r) => r.id).sort(); - const envRows = async (name: string) => - ((await ql.find('sys_metadata', { where: { type: 'position', name }, limit: 10 }, SYS)) as any[]) - .map((r) => ({ organization_id: r.organization_id ?? null, state: r.state })); - - beforeAll(async () => { - dir = mkdtempSync(join(tmpdir(), 'dogfood-held-position-')); - stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses a save of it', async () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - expect((ql.registry.getArtifactItem('position', HELD) as any)?._packageId).toBe('com.example.showcase'); - // [ADR-0131 D2] The package's declaration is the name's one home; the boot writes no row for it. - expect(await rowIds(HELD), 'no row is seeded').toEqual([]); - const put = await call('PUT', `/meta/position/${HELD}`, { name: HELD, label: 'Mine' }); - expect(outcome(put), JSON.stringify(put.json)).toEqual(DOOR_REFUSAL); - }); - - it.each([ - ['a package declares', HELD], - ['the platform declares as an audience anchor', ANCHOR], - ])('a create under a name %s answers the door\'s refusal and keeps no row and no definition', async (_holder, name) => { - const before = await rowIds(name); - const created = await call('POST', '/data/sys_position', { name, label: 'Mine' }); - expect(outcome(created), JSON.stringify(created.json)).toEqual(DOOR_REFUSAL); - expect(await rowIds(name), 'the row the write-through wrote first was undone').toEqual(before); - expect(await envRows(name)).toEqual([]); - }); - - it('a rename into a held name answers the same; the row keeps its name and its definition', async () => { - const created = await call('POST', '/data/sys_position', { name: 'held_e2_lane_lead', label: 'Lane lead' }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - const [id] = await rowIds('held_e2_lane_lead'); - const renamed = await call('PATCH', `/data/sys_position/${id}`, { name: HELD }); - expect(outcome(renamed), JSON.stringify(renamed.json)).toEqual(DOOR_REFUSAL); - expect(await rowIds('held_e2_lane_lead')).toEqual([id]); - expect(await envRows('held_e2_lane_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await envRows(HELD)).toEqual([]); - }); -}); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e184e421ee0..a4d912fdc5e 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -137,13 +137,14 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // An ORG-SCOPED sharing-admin permission set: `manage_sharing` and // deliberately NOT `manage_platform_settings` — the card's exposed class is // precisely the caller who holds the org capability and no platform one. - const psId = 'ps_8158_sharing_admin'; - await ql.insert('sys_permission_set', { - id: psId, + // Saved through the metadata door, the security catalog's one write path + // (ADR-0131 D3): a grant names it, and the resolver reads it from there. + const protocol = (await stack.kernel.getServiceAsync('protocol')) as any; + await protocol.saveMetaItem({ + type: 'permission', name: 'sharing_admin_8158', - label: 'Sharing Administrator (org-scoped)', - system_permissions: JSON.stringify(['manage_sharing']), - }, { context: SYS }); + item: { name: 'sharing_admin_8158', label: 'Sharing Administrator (org-scoped)', objects: {}, systemPermissions: ['manage_sharing'] }, + }); // Real sign-ups: better-auth's own path, through every database hook. await stack.signUp(ORG_LESS_EMAIL, PASSWORD); @@ -186,10 +187,9 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // A platform operator outside the organization: `admin_full_access` held // globally, the grant a platform administrator holds under `single`. await stack.signUp(OPERATOR_EMAIL, PASSWORD); - const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); - expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + expect(ql.registry.getItem('permission', 'admin_full_access'), 'admin_full_access is declared').toBeTruthy(); await ql.insert('sys_user_permission_set', { - user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, + user_id: await uid(OPERATOR_EMAIL), permission_set: 'admin_full_access', organization_id: null, }, { context: SYS }); // Sign in AFTER the grants, so every session is minted by the same path diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 01b6bcf89ac..1738fb568d8 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -343,13 +343,12 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const email = `crudmx-${set.replace(/^showcase_/, '')}@verify.test`; const token = await stack.signUp(email); if (set !== BASELINE_SET) { - const ps = await ql.findOne('sys_permission_set', { where: { name: set }, context: SYS }); - expect(ps?.id, `permission set ${set} seeded by the security bootstrap`).toBeTruthy(); + expect(ql.registry.getItem('permission', set), `permission set ${set} declared by the showcase`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; expect(uid, `persona ${email} provisioned`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', - { user_id: uid, permission_set: ps.name }, + { user_id: uid, permission_set: set }, { context: SYS }, ); } @@ -620,11 +619,12 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { // ── The exclusion is declared, not silent ──────────────────────────────── it('names the matrix rows this file does not judge (the sys_* delegate surface)', () => { - expect(UNJUDGED_ROWS.map((r) => r.object).sort()).toEqual([ + // The two retired catalog row objects (ADR-0131 D3) leave the matrix with + // C3 stage 8; the rest of the delegate surface stays. + const RETIRED = new Set(['sys_permission_set', 'sys_position']); + expect(UNJUDGED_ROWS.map((r) => r.object).filter((o) => !RETIRED.has(o)).sort()).toEqual([ 'sys_business_unit', 'sys_business_unit_member', - 'sys_permission_set', - 'sys_position', 'sys_user', 'sys_user_position', ]); diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 7060c491032..e064a386c7b 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -114,6 +114,8 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = 'showcase_member_default', 'member_default', ]); - expect(await ql.find('sys_position_permission_set', { where: {}, context: SYS }), 'no junction row is written').toEqual([]); + if (ql.getObject?.('sys_position_permission_set')) { + expect(await ql.find('sys_position_permission_set', { where: {}, context: SYS }), 'no junction row is written').toEqual([]); + } }); }); diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index c23377e797c..f63b0355a10 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -46,9 +46,9 @@ // lives only inside this test's stack — the showcase's committed metadata is // untouched, so what the stock app declares is unchanged. // -// Why a private boot rather than the worker-shared showcase: this file inserts a -// `sys_permission_set` row, which the shared harness's eligibility rules exclude -// outright (no permission-set metadata edits — see `shared-showcase.ts`). +// Why a private boot rather than the worker-shared showcase: this file saves a +// permission set, which the shared harness's eligibility rules exclude outright +// (no permission-set metadata edits — see `shared-showcase.ts`). // // ── One thing measured here and deliberately NOT asserted ──────────────────── // @@ -116,32 +116,27 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked // would come back readable and every assertion here would fail as if the // strip were broken. Deactivation is the activation ledger's (ADR-0131 D3); // this set has no ledger row, so it is in effect. - // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the - // write-through stores the definition the security catalog resolves. A - // system-context row insert has no definition and grants nothing. - const authored = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + // [ADR-0131 D3/D4] Authored through the metadata door, the security + // catalog's write path: the definition it stores is what the resolver reads. + const authored = await stack.apiAs(adminTok, 'PUT', `/meta/permission/${SCRATCH_SET}`, { name: SCRATCH_SET, label: 'Scratch: FLS read deny on showcase_project.budget', description: 'Test-authored (#9481). Stock showcase authors no readable:false grant — see #9308.', - object_permissions: JSON.stringify({}), + objects: {}, // Object-QUALIFIED key. A bare `budget` key silently enforces nothing: // the evaluator matches `.` prefixes, which is the exact // defect the permission-zoo audit found on the write half. - field_permissions: JSON.stringify({ + fields: { [`showcase_project.${DENIED}`]: { readable: false, editable: false }, - }), - system_permissions: JSON.stringify([]), - active: true, + }, }); - expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); - const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); - expect(ps?.id, 'scratch permission set authored').toBeTruthy(); + expect(authored.status, 'scratch set authored through the metadata door').toBeLessThan(300); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; expect(memberId, 'member provisioned').toBeTruthy(); await ql.insert( 'sys_user_permission_set', - { user_id: memberId, permission_set: ps.name }, + { user_id: memberId, permission_set: SCRATCH_SET }, { context: SYS }, ); diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts deleted file mode 100644 index 0c88b8c2dbc..00000000000 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// @proof: permission-set-projection -// -// ADR-0094 — `sys_permission_set` is a PURE PROJECTION of the metadata layer, -// proven on the real showcase stack. The record has no independent authority: -// -// 1. A data-door create/edit lands in the METADATA store (write-through) and -// the record is re-derived by the AWAITED projector — consistent on the -// very next read, no race. -// 2. [commit ee58392e1 inverted this pin] A data-door edit of a CODE-DECLARED set is -// REFUSED — `permission` rolled back to `allowOrgOverride: false` -// (ADR-0005 security row: "Authorization correctness; overlays would -// create silent privilege drift"), so overriding an artifact-backed set -// answers 403 `not_overridable` instead of minting an overlay. The -// behaviour before commit ee58392e1 (ADR-0094's 2026-07-14 "customize via env -// overlay" direction) is closed until an ADR-0005 revision readmits the -// type; ADR-0086 two-doors applies meanwhile (edit the package, -// re-publish). -// 3. Deleting a runtime-only set retires its record; deleting an -// artifact-backed set never removes it — the definition ships with the -// app and cannot be removed from the environment. Since commit ee58392e1 there is -// no overlay left to lift, so that "reset" is a no-op success, not the -// revert-to-the-declared-body step ADR-0094's 2026-07-14 direction -// described (retired by D5-R). -// 4. [commit ee58392e1 inverted this pin too] An environment-door metadata save that -// targets a package-owned, artifact-backed set is refused the same way — -// record, provenance and effective body all stay exactly as shipped. -// (Package-bound rows MATERIALIZED through the metadata door carry -// `sys_metadata` provenance, not an artifact, and stay editable through -// the `allowRuntimeCreate` tier — see two-doors-permission 块2.) - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; - -describe('sys_permission_set pure projection (ADR-0094)', () => { - let stack: VerifyStack; - let ql: any; - let protocol: any; - let adminToken: string; - - beforeAll(async () => { - stack = await bootShowcase(); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - protocol = await stack.kernel.getServiceAsync('protocol'); - }, 60_000); - afterAll(async () => { await stack?.stop(); }); - - const findSet = async (name: string) => - (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - const overlayBody = async (name: string) => { - const layered = await protocol.getMetaItemLayered({ type: 'permission', name }); - return layered?.overlay ?? null; - }; - - // ── 1. Data-door create → metadata store + awaited projection ───────────── - it('a data-door create lands in metadata and the record is projected before the response returns', async () => { - const NAME = 'proj_probe_agent'; - const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: NAME, - label: 'Projection Probe', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - system_permissions: JSON.stringify(['probe.use']), - }); - expect(res.status).toBeLessThan(300); - - // The record exists immediately (awaited projection — no poll/sleep) and is - // env-owned, not forged package provenance. - const row = await findSet(NAME); - expect(row, 'record projected synchronously with the create').toBeTruthy(); - expect(row.managed_by).toBe('admin'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - - // …and the authoritative store is the metadata overlay, not the row. - const overlay = await overlayBody(NAME); - expect(overlay?.name, 'the definition lives in the metadata store').toBe(NAME); - expect(overlay.systemPermissions).toEqual(['probe.use']); - }); - - it('a data-door edit updates metadata and the record follows in lock-step', async () => { - const NAME = 'proj_probe_agent'; - const row = await findSet(NAME); - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, { - system_permissions: JSON.stringify(['probe.use', 'probe.admin']), - }); - expect(res.status).toBeLessThan(300); - - expect((await overlayBody(NAME)).systemPermissions).toEqual(['probe.use', 'probe.admin']); - expect(JSON.parse((await findSet(NAME)).system_permissions)).toEqual(['probe.use', 'probe.admin']); - }); - - it('deleting a runtime-only set retires both the definition and the record', async () => { - const NAME = 'proj_probe_agent'; - const row = await findSet(NAME); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${row.id}`); - expect(res.status).toBeLessThan(300); - expect(await findSet(NAME), 'runtime-only record retired on delete').toBeFalsy(); - expect(await overlayBody(NAME), 'metadata overlay gone too').toBeFalsy(); - }); - - // ── 2. Data-door edit of a DECLARED set is REFUSED (commit ee58392e1) ───── - it('editing a declared set through the data door is refused — no overlay is minted', async () => { - // member_default is a platform-declared set (an artifact baseline - // exists), so the write-through's `saveMetaItem` hits the ADR-0005 type - // gate: `permission` is no longer `allowOrgOverride` (commit ee58392e1, the - // security row's "silent privilege drift"). The refusal must be LOUD — - // an error status, not a 2xx that quietly skipped the metadata write — - // and must leave no overlay behind (#6190's phantom-write shape is the - // thing the rollback forbids). - expect(await overlayBody('member_default'), 'no overlay before the edit').toBeFalsy(); - const md = await findSet('member_default'); - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${md.id}`, { - description: 'customized via Setup (ADR-0094)', - }); - expect(res.status, 'the refusal surfaces to the data-door caller').toBe(403); - - // Nothing was minted and nothing enforces differently: no overlay row, - // record description unchanged. - expect(await overlayBody('member_default'), 'no overlay after the refusal either').toBeFalsy(); - expect((await findSet('member_default')).description ?? null).not.toBe('customized via Setup (ADR-0094)'); - }); - - // ── 3. Delete of an artifact-backed set RESETS (does not remove) ────────── - it('deleting a declared set through the data door resets it to the declared body, keeping the record', async () => { - // (commit ee58392e1: with the edit above refused, there is no overlay to lift — the - // delete is a no-op reset. The invariant it pins is unchanged: a - // declared definition cannot be removed from the environment.) - const before = await findSet('member_default'); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); - expect(res.status).toBeLessThan(300); - // [commit f9e16d856] The status cannot tell this reset apart from a real deletion — - // the whole envelope used to be byte-identical to one, so every assertion - // below stayed green while the door told the caller the set was gone. - // `success: false` is the one field that says the record is still here, - // and this is the end-to-end pin on it. - expect((await res.json())?.success, 'a reset does not report a deletion').toBe(false); - - const after = await findSet('member_default'); - expect(after, 'a packaged/declared set cannot be removed from the environment').toBeTruthy(); - // No overlay (none could be created) and no customized description. - expect(await overlayBody('member_default')).toBeFalsy(); - expect(after.description ?? null).not.toBe('customized via Setup (ADR-0094)'); - }); - - // ── 4. Env overlay of a PACKAGE set is REFUSED (commit ee58392e1) ───────── - it('an environment-door metadata save on a package-owned set is refused and changes nothing', async () => { - const contributor = await findSet('showcase_contributor'); - expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); - const layeredBefore = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - const baseline = layeredBefore?.code ?? null; - expect(baseline, 'the packaged declaration is the code layer').toBeTruthy(); - - // Commit ee58392e1 — `permission` rolled back to `allowOrgOverride: false` - // (ADR-0005 security row). The overlay ADR-0094's 2026-07-14 direction - // used here is exactly the per-org shadowing of a code-shipped - // authorization contract the ADR forbids, so the save refuses LOUDLY at - // the moment of the write instead of minting an enforced overlay. - await expect( - protocol.saveMetaItem({ - type: 'permission', - name: 'showcase_contributor', - item: { ...baseline, label: 'Contributor (env customized)' }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - // Nothing moved: record, provenance, customized flag and the effective - // body all stay exactly as shipped. - const after = await findSet('showcase_contributor'); - expect(after.label).toBe(contributor.label); - expect(after.managed_by).toBe('package'); - expect(after.package_id).toBe(contributor.package_id); - expect(after.customized).toBe(false); - const layeredAfter = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layeredAfter?.overlay ?? null, 'no overlay row was minted').toBeFalsy(); - }); - - it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => { - const NAME = 'proj_env_authored'; - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - item: { name: NAME, label: 'Env Authored', objects: { crm_lead: { allowRead: true } }, systemPermissions: ['env.use'] }, - }); - // Studio-authored env sets now surface in Setup (the record is created by - // the projector, not left invisible as before ADR-0094). - const row = await findSet(NAME); - expect(row, 'Studio-authored env set appears in Setup').toBeTruthy(); - expect(row.managed_by).toBe('admin'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); -}); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index b7268aebcbf..0c1fd7385f8 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -129,32 +129,10 @@ describe('showcase: ADR-0090 permission-model zoo', () => { expect(names, 'the granting set is attributed').toContain('showcase_auditor'); }); - /** - * [ADR-0131 D2] No boot writes a `sys_position` row. The junction-write - * gates below judge a write naming a ROW, so each case reads the row an - * existing database holds — what the retired seeders wrote — and stages it - * when this (shared) stack has none yet. - */ - const existingPositionRow = async (name: string, managedBy: 'platform' | 'package') => { - const found = await ql.findOne('sys_position', { where: { name }, context: SYS }); - if (found) return found; - await ql.insert('sys_position', { id: `pos_zoo_${name}`, name, label: name, managed_by: managedBy }, { context: SYS }); - return ql.findOne('sys_position', { where: { name }, context: SYS }); - }; - // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { - // Bind contributor → showcase_contributor first (admin action; the - // delegate has manageBindings: false and must NOT be able to do this). - const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(pos?.id && ps?.id, 'contributor position + set rows').toBeTruthy(); - const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: pos.id, - permission_set_id: ps.id, - }); - expect(bind.status, 'tenant admin binds set to position').toBeLessThan(300); - + // [ADR-0131 D3/D4] `contributor` binds `showcase_contributor` on its own + // definition (`permissionSets`), which the allowlist judges; no binding row. const assign = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { user_id: targetId, position: 'contributor', @@ -188,38 +166,11 @@ describe('showcase: ADR-0090 permission-model zoo', () => { expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); }); - it('the delegate cannot re-compose positions (manageBindings: false)', async () => { - const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); - const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { - position_id: pos.id, - permission_set_id: ps.id, - }); - expect(r.status, 'binding writes need manageBindings').not.toBeLessThan(300); - }); - - // ── ADR-0090 D5/D9: the audience-anchor binding gate ───────────────────── - it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { - const everyone = await existingPositionRow('everyone', 'platform'); - expect(everyone, 'built-in everyone position row').toBeTruthy(); - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); - const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: everyone.id, - permission_set_id: auditor.id, - }); - expect(r.status, 'viewAllRecords on the everyone anchor is lint-tier blocked at runtime').not.toBeLessThan(300); - }); - - it('binding the guest-safe set to the guest anchor is accepted', async () => { - const guest = await existingPositionRow('guest', 'platform'); - expect(guest, 'built-in guest position row').toBeTruthy(); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); - const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: guest.id, - permission_set_id: ps.id, - }); - expect(r.status, 'read-only + create-intake set passes the guest tier').toBeLessThan(300); - }); + // [C3 stage 8] The junction-write gates this file pinned here — a delegate + // re-composing a position, a high-privilege set bound to the `everyone` + // anchor, the guest-safe set bound to `guest` — judged rows of the retired + // `sys_position_permission_set` object (ADR-0131 D3). A position's sets are + // its definition's `permissionSets`, written through the metadata door. // ── FLS enforcement — object-qualified keys (permission-zoo audit) ─────── // The contributor set masks the project budget figures read-only. The keys diff --git a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts deleted file mode 100644 index a7178dcdd40..00000000000 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// ADR-0086 P2 — "two doors" separation for permission sets, proven on the real -// showcase stack (which declares `showcase_contributor` as a package set): -// -// 块1 — the PACKAGE door: a permission set authored as a `permission` metadata -// draft under a package and then PUBLISHED is materialized into -// `sys_permission_set` with `managed_by:'package'` + the owning -// `package_id` (publish-time, not just at boot). A draft alone -// materializes nothing — only publish makes it live. -// -// 块2 — the ADMIN door (evolved by ADR-0094; its 2026-07-14 "translate the -// edit into an env-scope ADR-0005 overlay" direction was RETIRED on -// 2026-08-09 — see ADR-0094 D5-R): a data-plane edit of a -// package-managed, ARTIFACT-BACKED row is REFUSED with 403 -// `not_overridable` and no overlay is minted — commit ee58392e1 rolled -// `permission` back to `allowOrgOverride: false`, and ADR-0086 names -// the supported channel instead: edit the package and re-publish. -// A "delete" through this door still degrades to a RESET — a packaged -// definition can never be removed from the environment — though with -// no overlay left to lift there is nothing for it to revert. A set -// authored through the data door rides the still-open -// `allowRuntimeCreate` tier and stays editable. -// Forging package provenance through the admin door stays refused. - -import { describe, it, expect, beforeAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { getSharedShowcase } from './shared-showcase.js'; - -describe('two-doors permission separation (ADR-0086 P2)', () => { - let stack: VerifyStack; - let ql: any; - let protocol: any; - let adminToken: string; - - beforeAll(async () => { - stack = await getSharedShowcase(); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - protocol = await stack.kernel.getServiceAsync('protocol'); - }, 60_000); - - const findSet = async (name: string) => - (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - - // ── 块1 — package door: draft → publish → materialize ──────────────────── - it('块1: publishing a package permission draft materializes a package-managed row', async () => { - // An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage), - // standing in for the package the studio package door edits. - const PKG = 'com.example.twodoors_ws'; - const NAME = 'twodoors_pkgset'; - - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - mode: 'draft', - packageId: PKG, - item: { - name: NAME, - label: 'Two Doors Set', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - }, - }); - - // Draft only — enforcement/admin-surface must NOT see it yet. - expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy(); - - const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME }); - expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy(); - expect(pub.materializeApplied.success).toBe(true); - - const row = await findSet(NAME); - expect(row, 'published set is now a real record').toBeTruthy(); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe(PKG); - expect(JSON.parse(row.object_permissions || '{}')).toEqual({ - crm_lead: { allowRead: true, allowCreate: true }, - }); - }); - - // ── 块2 — admin door: artifact-backed rows now REFUSE the overlay (commit ee58392e1) ─ - it('块2: an admin edit of a package-managed set is refused — no env overlay is minted (#6483)', async () => { - // Before commit ee58392e1 this edit became an env-scope overlay (ADR-0094's 2026-07-14 - // direction). `permission` has since rolled back to - // `allowOrgOverride: false` (ADR-0005 security row: "Authorization - // correctness; overlays would create silent privilege drift"), so the - // write-through's `saveMetaItem` refuses the artifact-backed target with - // 403 `not_overridable` — loudly, at the moment of the write. ADR-0086 - // two-doors names the supported channel: edit the package, re-publish. - const contributor = await findSet('showcase_contributor'); - expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${contributor.id}`, { - label: 'Contributor (customized)', - }); - expect(res.status, 'the refusal surfaces to the data-door caller').toBe(403); - - // Nothing was minted and nothing projects differently. - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layered?.overlay ?? null, 'no env-scope overlay row exists').toBeFalsy(); - const after = await findSet('showcase_contributor'); - expect(after.label).toBe(contributor.label); - expect(after.managed_by).toBe('package'); - expect(after.package_id).toBe(contributor.package_id); - }); - - it('块2: "deleting" the package set through the env door still RESETS to the shipped declaration', async () => { - // (commit ee58392e1: with the edit above refused there is no overlay to lift, but - // the invariant is unchanged and still pinned: the env door can never - // remove a packaged definition — delete degrades to reset.) - const before = await findSet('showcase_contributor'); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); - expect(res.status).toBeLessThan(300); - // [commit f9e16d856] The status cannot tell this reset apart from a real deletion — - // the whole envelope used to be byte-identical to one, so every assertion - // below stayed green while the door told the caller the set was gone. - // `success: false` is the one field that says the record is still here, - // and this is the end-to-end pin on it. - expect((await res.json())?.success, 'a reset does not report a deletion').toBe(false); - - const after = await findSet('showcase_contributor'); - expect(after, 'a packaged definition is never removed by the env door').toBeTruthy(); - expect(after.label, 'label reset to the shipped declaration').not.toBe('Contributor (customized)'); - expect(after.managed_by).toBe('package'); - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layered?.overlay, 'overlay gone after the reset').toBeFalsy(); - }); - - it('块2: the admin door CAN still edit an env-authored set (isolates the gate to artifact-backed rows)', async () => { - // The specimen was `member_default` until commit ee58392e1: its record is env-owned - // (`managed_by` ≠ 'package'), but its METADATA identity is a platform - // artifact (`defaultPermissionSets`), so with `permission` rolled back - // to `allowOrgOverride: false` the write-through's `saveMetaItem` now - // refuses to overlay it — the record's provenance column was never what - // the gate reads. A truly env-AUTHORED set (created through the data - // door, definition living only in `sys_metadata`) rides the - // `allowRuntimeCreate` tier, which commit ee58392e1 deliberately left open — that - // is the boundary this case isolates. - const NAME = 'twodoors_env_authored'; - const created = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: NAME, - label: 'Env Authored (two doors)', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - }); - expect(created.status).toBeLessThan(300); - const row = await findSet(NAME); - expect(row?.managed_by, 'env-authored set is admin-owned').toBe('admin'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, { - description: 'edited through the admin door', - }); - expect(res.status).toBeLessThan(300); - - const after = await findSet(NAME); - expect(after.description).toBe('edited through the admin door'); - }); - - it('块2: the admin door can NO LONGER edit an artifact-backed set (#6483 — the gate reads artifact provenance)', async () => { - // The other half of the boundary: `member_default`'s definition ships as - // a platform artifact, so the same PATCH that works on an env-authored - // set answers 403 `not_overridable` here — loudly, at the moment of the - // write, with no overlay minted (ADR-0005 security row; ADR-0086 - // two-doors names the supported channel: edit the package, re-publish). - const memberDefault = await findSet('member_default'); - expect(memberDefault?.managed_by ?? null, 'member_default record is env-owned').not.toBe('package'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${memberDefault.id}`, { - description: 'edited through the admin door', - }); - expect(res.status, 'artifact-backed set refuses the data-door edit').toBe(403); - - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'member_default' }); - expect(layered?.overlay ?? null, 'no overlay row was minted by the refused edit').toBeFalsy(); - }); - - it('块2: the admin door cannot forge package provenance on insert', async () => { - const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: 'forged_pkg_set', - label: 'Forged', - managed_by: 'package', - package_id: 'com.example.twodoors_ws', - }); - expect(res.status).toBe(403); - expect(await findSet('forged_pkg_set'), 'the forged row must not exist').toBeFalsy(); - }); -}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 825b6552165..0516660b120 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -75,7 +75,6 @@ const SHARED_SHOWCASE = [ 'test/showcase-readonly-when-parent.dogfood.test.ts', 'test/showcase-search.dogfood.test.ts', 'test/showcase-static-readonly.dogfood.test.ts', - 'test/two-doors-permission.dogfood.test.ts', ]; // Commit 08f5f0e5a / #17978 — say so when a path named on the command line will run no diff --git a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts index 011de0d05e8..0097f6c1d3f 100644 --- a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts +++ b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts @@ -110,8 +110,10 @@ const expectRegimeC = (message: unknown, type: string, name: string, operation: }; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}'; +// The route's spelling moved with C3 stage 8 (the retired object's data route +// → the catalog's clone door), so the sentence is pinned up to it. const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).'; + + 'or POST /api/v1/'; describe('PUT / DELETE /api/v1/meta/action/:name on a packaged action — the refusal names the activation switch', () => { it('PUT answers 403 NOT_OVERRIDABLE naming the switch, no clone, no hatch', async () => { diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a33e457181a..da88c4b875b 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -191,6 +191,7 @@ interface ObjectQlRegistrySlot { /** The private field `plugin-sharing`'s `readDeclared` spells instead. */ readonly _registry: unknown; find(object: string, query: Record): Promise; + getObject?(object: string): unknown; } interface MetadataListSlot { @@ -258,7 +259,8 @@ beforeAll(async () => { for (const t of ['sys_permission_set', 'sys_capability', 'sys_sharing_rule']) { // The seeded rows as the platform reads them: the explicit system opt-in. // A context-less read is refused by the security middleware (ADR-0096 D5). - rows[t] = await ql.find(t, { context: { isSystem: true } }); + // [C3 stage 8] A retired catalog row object has no table, and no rows. + rows[t] = ql.getObject?.(t) ? await ql.find(t, { context: { isSystem: true } }) : []; } }, BOOT_TIMEOUT); @@ -383,34 +385,9 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(door).toMatchObject({ isDefault: false, _packageVersion: '3.0.0' }); }); - it('⚠️ NOT ABSORBED — `sys_permission_set` persists the UN-PARSED registry copy, byte for byte', () => { - const reg = byName(registryCopy.permission, 'probe_agent'); - const door = byName(doorCopy.permission, 'probe_agent'); - const row = byName(rows.sys_permission_set, 'probe_agent'); - - // The decisive read: the stored authorization map is deep-equal to the - // registry copy's and NOT to the door's. Nothing between `readDeclared` and - // `tryInsert` re-parses it — `permissionSetRowFields` serialises what it - // was handed. - const storedObjects = JSON.parse(row.object_permissions); - expect(storedObjects).toEqual(reg.objects); - expect(storedObjects).not.toEqual(door.objects); - - const storedRls = JSON.parse(row.row_level_security); - expect(storedRls).toEqual(reg.rowLevelSecurity); - expect(storedRls).not.toEqual(door.rowLevelSecurity); - - // Spelled out key by key, so a future change to either side names itself. - expect(storedObjects.crm_ticket).toMatchObject({ allowRestore: true, allowPurge: false }); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowTransfer'); - expect(storedObjects.crm_ticket).not.toHaveProperty('viewAllRecords'); - expect(storedObjects.crm_ticket).not.toHaveProperty('modifyAllRecords'); - expect(storedRls[0]).toMatchObject({ priority: 10 }); - - expect(row).toMatchObject({ - managed_by: 'package', package_id: 'com.test.issue-14491', active: true, customized: false, - }); - }); + // [ADR-0131 D3] Which copy a `sys_permission_set` row persisted is no longer + // a question: no boot writes that row (C3 stage 6b-2), and the object itself + // is retired (C3 stage 8). The registry copy is what the catalog serves. it('`sharing_rule`: the registry copy keeps the legacy `sharedWith.type` and `accessLevel` the door forward-converts', () => { const regLegacy = byName(registryCopy.sharing_rule, 'share_legacy_deals'); diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 6fb3bacd855..2e078438f1c 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -29,8 +29,6 @@ describe('SystemObjectName', () => { expect(SystemObjectName.DEVICE_CODE).toBe('sys_device_code'); expect(SystemObjectName.JWKS).toBe('sys_jwks'); expect(SystemObjectName.USER_PREFERENCE).toBe('sys_user_preference'); - expect(SystemObjectName.POSITION).toBe('sys_position'); - expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set'); expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log'); expect(SystemObjectName.METADATA).toBe('sys_metadata'); expect(SystemObjectName.PRESENCE).toBe('sys_presence'); @@ -43,7 +41,6 @@ describe('SystemObjectName', () => { expect(names).toContain('sys_organization'); expect(names).toContain('sys_team'); expect(names).toContain('sys_team_member'); - expect(names).toContain('sys_position'); expect(names).toContain('sys_audit_log'); expect(names).toContain('sys_presence'); }); @@ -62,8 +59,6 @@ describe('SystemObjectName', () => { expect(keys).toContain('API_KEY'); expect(keys).toContain('TWO_FACTOR'); expect(keys).toContain('USER_PREFERENCE'); - expect(keys).toContain('POSITION'); - expect(keys).toContain('PERMISSION_SET'); expect(keys).toContain('AUDIT_LOG'); expect(keys).toContain('METADATA'); expect(keys).toContain('PRESENCE'); From dbae752f6e94a205ec7898849b88b53d47f92c1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:30:19 +0000 Subject: [PATCH 18/39] feat(plugin-security)!: retire the four security-catalog row objects (#15204 stage 8) sys_position, sys_permission_set, sys_position_permission_set and sys_capability are no longer registered (ADR-0131 D3): their data and REST routes and their Setup object pages go; the tables and rows stay for the organization-ownership migration (D10). - E1: the permission mutation projector and its reconcile, both data-door write-throughs and the row-only position backfill are unwired; a metadata-door save reaches the catalog through the door's own registry write-through. - E3: sys_user_permission_set.permission_set_id is Field.text (maxLength 255): same column, ids and indexes. - E6: the first-admin promotion reads no catalog row; an unscoped grant that names no set and carries a legacy id still withholds it. - Uninstall revokes every grant naming a set the package declared; the doors read the names before the withdrawal (declared). - Setup nav opens the registry lists; residue lists, prose, help text, the lint seed-row source and isRowActive go; translations, censuses, reference docs and the keyed-text-bounds record are regenerated; an ADR-0087 semantic entry registers the retirement. Claude-Session: https://claude.ai/code/session_01MGoWgeE8rhsrig77V4oUZn Co-authored-by: Claude --- .../15204-s8-catalog-row-objects-retired.md | 46 ++ .../docs/permissions/tenant-audit-census.mdx | 42 +- .../docs/references/security/permission.mdx | 2 +- .../docs/references/system/auth-config.mdx | 4 +- ...08-tenant-audit-write-call-sites.counts.md | 26 +- .../areas/access-security.json | 13 +- docs/qa/platform-checklist/coverage.json | 1 - examples/app-showcase/access-matrix.json | 20 - examples/app-showcase/objectstack.config.ts | 4 +- examples/app-showcase/src/coverage.ts | 2 +- .../src/security/permission-sets.ts | 2 - .../src/marketplace-install-local-plugin.ts | 28 +- packages/cloud-connection/vitest.config.ts | 9 + .../src/security/admin-standing-surface.ts | 2 +- packages/core/src/security/index.ts | 5 - .../src/security/resolve-user-grants-cache.ts | 3 - packages/core/src/security/row-active.ts | 78 ---- packages/lint/src/rule-explanations.ts | 10 +- ...ate-capability-references.seed-row.test.ts | 27 ++ .../src/validate-capability-references.ts | 23 +- packages/metadata-protocol/src/index.ts | 3 +- .../src/packaged-base-regime.ts | 2 +- packages/metadata-protocol/src/protocol.ts | 71 ++- .../src/tenancy/platform-object-tenancy.ts | 16 - .../en.metadata-forms.generated.ts | 2 +- .../src/apps/translations/en.ts | 1 - .../es-ES.metadata-forms.generated.ts | 2 +- .../apps/translations/es-ES.source-hashes.ts | 1 - .../src/apps/translations/es-ES.ts | 1 - .../ja-JP.metadata-forms.generated.ts | 2 +- .../apps/translations/ja-JP.source-hashes.ts | 1 - .../src/apps/translations/ja-JP.ts | 1 - .../zh-CN.metadata-forms.generated.ts | 2 +- .../apps/translations/zh-CN.source-hashes.ts | 1 - .../src/apps/translations/zh-CN.ts | 1 - packages/platform-objects/src/pages/index.ts | 1 - .../src/pages/sys-position.page.ts | 113 ----- .../scripts/i18n-extract.config.ts | 13 +- .../src/bootstrap-platform-admin.ts | 103 ++--- .../src/cleanup-package-permissions.ts | 82 ++-- .../plugins/plugin-security/src/manifest.ts | 17 +- .../src/objects/default-permission-sets.ts | 3 - .../plugin-security/src/objects/index.ts | 4 - .../src/objects/sys-capability.object.ts | 294 ------------- .../src/objects/sys-permission-set.object.ts | 364 ---------------- .../sys-position-permission-set.object.ts | 106 ----- .../src/objects/sys-position.object.ts | 412 ------------------ .../objects/sys-user-permission-set.object.ts | 14 +- .../src/packaged-permission-set-lock.ts | 4 +- .../plugin-security/src/security-plugin.ts | 154 +------ .../src/translations/en.objects.generated.ts | 304 +------------ .../translations/es-ES.objects.generated.ts | 302 ------------- .../es-ES.source-hashes.generated.ts | 3 - .../translations/ja-JP.objects.generated.ts | 302 ------------- .../ja-JP.source-hashes.generated.ts | 3 - .../translations/zh-CN.objects.generated.ts | 302 ------------- .../zh-CN.source-hashes.generated.ts | 3 - .../plugin-sharing/src/sharing-service.ts | 3 - .../src/manifests/auth.manifest.ts | 2 +- .../service-settings/src/translations/en.ts | 2 +- .../src/translations/es-ES.ts | 2 +- .../src/translations/ja-JP.ts | 2 +- .../src/translations/zh-CN.ts | 2 +- .../18.catalog-row-objects-retired.ts | 31 ++ packages/spec/src/security/permission.form.ts | 2 +- packages/spec/src/security/permission.zod.ts | 2 +- packages/spec/src/system/auth-config.zod.ts | 2 +- .../system/constants/platform-object-names.ts | 4 - .../spec/src/system/constants/system-names.ts | 4 - scripts/check-keyed-text-bounds.mjs | 29 +- scripts/platform-object-tenancy-census.json | 32 +- scripts/tenant-audit-census.mjs | 12 + 72 files changed, 443 insertions(+), 3045 deletions(-) create mode 100644 .changeset/15204-s8-catalog-row-objects-retired.md delete mode 100644 packages/core/src/security/row-active.ts create mode 100644 packages/lint/src/validate-capability-references.seed-row.test.ts delete mode 100644 packages/platform-objects/src/pages/sys-position.page.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-capability.object.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-position.object.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts diff --git a/.changeset/15204-s8-catalog-row-objects-retired.md b/.changeset/15204-s8-catalog-row-objects-retired.md new file mode 100644 index 00000000000..7f2269519a0 --- /dev/null +++ b/.changeset/15204-s8-catalog-row-objects-retired.md @@ -0,0 +1,46 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/platform-objects': minor +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/metadata-protocol': minor +'@objectstack/cloud-connection': patch +'@objectstack/objectql': patch +'@objectstack/plugin-sharing': patch +'@objectstack/lint': patch +'@objectstack/service-settings': patch +--- + +feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + +FROM → TO: + +- `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. +- `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. +- `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). +- `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). +- Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. +- An object permission on one of the four names in a permission set grants nothing and can be deleted. + +What else changes: + +- `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. +- A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. +- On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. +- Uninstalling a package revokes every grant naming a permission set the package declared. The uninstalling door reads those names off the package before withdrawing it and hands them to each uninstall cleanup as `declared` (`UninstallCleanup`, `@objectstack/metadata-protocol`; `declaredCatalogItemsOf` is exported). +- `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + +Removed exports, no replacement: + +- `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. +- `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. +- `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. +- `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f14126367c8..c603dde9d80 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 44 of them — just under a fifth +typed `any` have no type to read, and there are 42 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 227 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **227** | +| 175 write call sites | quoted in the merged changeset | **225** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 227** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 225** decidable, **70** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 44 sites reached +one, and both are counted in the generated tables below: the 42 sites reached through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a +120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 227`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,30 +223,30 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **227** | +| write call sites on the application surface | **225** | | …whose object name is statically decidable | 155 | -| …whose object name is chosen at run time | 72 | -| …against an object with tenancy ENABLED | 154 | +| …whose object name is chosen at run time | 70 | +| …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 166 | +| threading a tenant context | 164 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 122 | +| threading a decidably ELEVATED (`isSystem`) context | 120 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 97 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 183 | -| receiver erased, placed by the object NAME | 30 | -| receiver erased, placed by an `object: string` PARAMETER | 9 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | +| receiver erased, placed by the object NAME | 27 | +| receiver erased, placed by an `object: string` PARAMETER | 7 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 8 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | -| object name is an `object: string` parameter | 11 | +| object name is an `object: string` parameter | 9 | | object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `c9ef4fbce`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | -| declared objects in the registry | 117 | +| tracked non-test sources scanned | 624 | +| engine-shaped types recognised | 71 | +| declared objects in the registry | 113 | | same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 224659ec51f..990a8c48730 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -116,7 +116,7 @@ const result = AdminScopeSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) | | **label** | `string` | optional | Display label | -| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) | +| **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) | | **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | | **isDefault** | `boolean` | optional (default: `false`) | [ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 23953ae00a1..9052efb607e 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -54,7 +54,7 @@ Advanced / low-level Better-Auth options | :--- | :--- | :--- | :--- | | **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. | | **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. | -| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | +| **selfRegistrationPermissionSet** | `string` | optional | Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | --- @@ -171,7 +171,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | :--- | :--- | :--- | :--- | | **posture** | `Enum<'invite_only' \| 'email_domain' \| 'open'>` | optional (default: `"invite_only"`) | Who may self-register into this environment: invite_only (default — operator acts only), email_domain (allowlisted email domains), or open (anyone). email_domain forces email verification on; open forces it on unless the deployment explicitly sets emailAndPassword.requireEmailVerification to false. | | **allowedEmailDomains** | `string[]` | optional | Email domains admitted to self-register under posture email_domain (exact, case-insensitive match; subdomains need their own entries). Required non-empty for email_domain; refused under other postures. | -| **selfRegistrationPermissionSet** | `string` | optional | sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | +| **selfRegistrationPermissionSet** | `string` | optional | Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; refused for invite_only. admin_full_access is refused. | ### Nested Shape: `AuthConfig.advanced` diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9ca4d164a81..91408e89fb9 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 227 | +| Write call sites | 225 | | Object name statically decidable | 155 | -| Object name chosen at run time | 72 | -| Against a tenancy-enabled object | 154 | +| Object name chosen at run time | 70 | +| Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 166 | +| Threading a tenant context | 164 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 122 | +| Threading a decidably elevated context | 120 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 97 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-11 at `be55432f6`. +Measured on 2026-10-12 at `c9ef4fbce`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 632 | -| engine-shaped types recognised | 72 | -| declared objects in the registry | 117 | +| tracked non-test sources scanned | 624 | +| engine-shaped types recognised | 71 | +| declared objects in the registry | 113 | | same-named calls subtracted as non-engine | 161 | ## Every site @@ -156,19 +156,17 @@ Measured on 2026-10-11 at `be55432f6`. | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/normalize-managed-by.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | undeclared-name | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index e379a6cbc9b..53b580cdafc 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2557,8 +2557,9 @@ "id": "access-security.packaged-permission-set-lifecycle", "title": "Packaged permission-set lifecycle (#11513) survives ADR-0126 unchanged: locked base at the data door, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it writes a sys_metadata_activation row", "since": "v16", - "status": "active", - "revision": 1, + "status": "retired", + "retiredReason": "C3 stage 8 (ADR-0131 D3) retired the sys_permission_set row object: its data door, its Activate / Deactivate / Clone row actions and its `active` column are gone. A packaged set's base stays locked at the metadata door, a copy is made with POST /api/v1/security/permission-sets/:name/clone, and a set is switched off in the activation ledger (sys_metadata_activation).", + "revision": 2, "priority": "P2", "surface": "mixed", "personas": [ @@ -2643,7 +2644,7 @@ "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", "packages/plugins/plugin-security/src/permission-set-projection.ts (insert-path lock + duplicate 409; the #4669 row-state carve-out ordered BEFORE the update-path lock)", - "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts#bodyExtra (activate/deactivate bodyExtra {active}, clone_permission_set params — the payload contract)", + "packages/runtime/src/domains/permission-set-clone.ts (the clone door that replaced the retired object's clone_permission_set action)", "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" ], @@ -2653,6 +2654,12 @@ "date": "2026-08-26", "change": "new — the landed #11513 surface (data-door lock, clone action, active row-state lifecycle) had no checklist item: readonly-package-locks-studio covers the /meta door on objects and permission-matrix-edit-loop explicitly scopes itself to WRITABLE sets, so the packaged-set data-door lifecycle was uncovered end to end. Authored as the ADR-0126 regression guard with the no-ledger-row wall as its ⭐ clause, per the 2026-08-26 ruling in #12159 (card 已知边界 4): flipping active or cloning must keep writing sys_permission_set state only, never a sys_metadata_activation row", "ref": "#12438" + }, + { + "revision": 2, + "date": "2026-10-12", + "change": "retired — the sys_permission_set row object, its data door and its row actions are retired with C3 stage 8 (ADR-0131 D3)", + "ref": "#15204" } ] }, diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 542fd62c23c..4b2a7020885 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -223,7 +223,6 @@ "access-security.sharing-rule-authoring-ui", "access-security.owd-save-gate", "access-security.share-link-capability-tokens", - "access-security.packaged-permission-set-lifecycle", "access-security.platform-owner-email-anchor", "access-security.me-permissions-aggregation-parity", "api-backend.api-methods-verb-gate" diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json index 721b25a7e42..6d948776253 100644 --- a/examples/app-showcase/access-matrix.json +++ b/examples/app-showcase/access-matrix.json @@ -199,26 +199,6 @@ "viewAllRecords": false, "modifyAllRecords": false }, - { - "permissionSet": "showcase_field_ops_delegate", - "object": "sys_permission_set", - "create": false, - "read": true, - "edit": false, - "delete": false, - "viewAllRecords": false, - "modifyAllRecords": false - }, - { - "permissionSet": "showcase_field_ops_delegate", - "object": "sys_position", - "create": false, - "read": true, - "edit": false, - "delete": false, - "viewAllRecords": false, - "modifyAllRecords": false - }, { "permissionSet": "showcase_field_ops_delegate", "object": "sys_user", diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index f1777eba834..7d3fdc9a097 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -269,8 +269,8 @@ export default defineStack({ // Security positions: allPositions, permissions: allPermissionSets, - // [ADR-0066 D1] Package-declared authorization capabilities — seeded into - // sys_capability with package provenance (managed_by:'package'). + // [ADR-0066 D1] Package-declared authorization capabilities — served from + // the security catalog under this package (ADR-0131 D3). capabilities: allCapabilities, sharingRules: allSharingRules, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 91f737b815a..49c98f7ed97 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -165,7 +165,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/security/capabilities.ts'], notes: - 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), seeded into sys_capability at publish. Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', + 'Package-declared authorization capabilities (`capabilities: allCapabilities` in objectstack.config.ts), served from the security catalog under the package (ADR-0131 D3). Code-only kind: runtime creation answers 403 not_creatable, which protocol.capability-write-door.test.ts pins.', }, // ── ai ── diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 62a413f870b..4af023b5427 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -391,8 +391,6 @@ export const FieldOpsDelegatePermissionSet = definePermissionSet({ label: 'Field Ops Delegate Admin', objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, - sys_position: { allowRead: true }, - sys_permission_set: { allowRead: true }, sys_business_unit: { allowRead: true }, sys_business_unit_member: { allowRead: true }, sys_user: { allowRead: true }, diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 10b51ce6aae..6199ae4befe 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -136,7 +136,8 @@ import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import type { IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -import type { DeletePackageRequest, UninstallCleanupOutcome } from '@objectstack/metadata-protocol'; +import type { DeletePackageRequest, UninstallCleanupOutcome, UninstalledCatalogItem } from '@objectstack/metadata-protocol'; +import { declaredCatalogItemsOf } from '@objectstack/metadata-protocol'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -159,7 +160,7 @@ const ROUTE_BASE = '/api/v1/marketplace/install-local'; */ type UninstallCleanupRunner = { runUninstallCleanups?( - request: Pick, + request: Pick & { declared?: ReadonlyArray }, ): Promise; }; @@ -1658,12 +1659,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // they had just selected and removed. The cleanups lose nothing by // going second: `deletePackage` already runs every registered cleanup // after its withdrawal, and the one registered today - // (`security.package-permissions`) selects by package id in the store, + // (`security.package-permissions`) revokes by the names read above, // never through the registry. + // [ADR-0131 D3, C3 stage 8] What the package declared in the security + // catalog, read before the withdrawal takes it out of the registry: the + // cleanups revoke the grants that name those items. + const declared = this.declaredCatalogItems(ctx, manifestId); const withdrawal = this.withdrawFromRunningKernel(ctx, manifestId); const cleanups = [ ...(withdrawal ? [withdrawal] : []), - ...await this.runUninstallCleanups(ctx, manifestId, admission.userId), + ...await this.runUninstallCleanups(ctx, manifestId, admission.userId, declared), ]; ctx.logger?.info?.( `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` @@ -1786,10 +1791,21 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * tell "nothing to revoke" from "the revocation never ran"; * - otherwise the runner's outcomes, verbatim. */ + /** The security-catalog items a still-registered package declares (`declaredCatalogItemsOf`). */ + private declaredCatalogItems = (ctx: PluginContext, manifestId: string): UninstalledCatalogItem[] => { + try { + const ql = ctx.getService('objectql'); + return declaredCatalogItemsOf((ql?.registry as any)?.getPackage?.(manifestId)?.manifest); + } catch { + return []; + } + }; + private runUninstallCleanups = async ( ctx: PluginContext, manifestId: string, actor: string, + declared: ReadonlyArray = [], ): Promise => { let protocol: UninstallCleanupRunner | undefined; try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } @@ -1806,7 +1822,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ); } else { try { - outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor }); + outcomes = await protocol.runUninstallCleanups({ packageId: manifestId, actor, ...(declared.length > 0 ? { declared } : {}) }); } catch (err: any) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] the uninstall cleanups of ${manifestId} could not be run: ${err?.message ?? err}`); outcomes = notRun('the uninstall cleanups could not be run'); @@ -2407,7 +2423,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] ${manifestId} is installed, but the metadata:reloaded re-sync FAILED — its ` - + 'record-change flows may not fire and its permission sets may be missing from sys_permission_set ' + + 'record-change flows may not fire ' + `until the runtime restarts (the restart re-reads every installed package). Cause: ${err?.message ?? err}`, ); } diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 0d1d4fe56bc..45ca20cb132 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -50,6 +50,15 @@ export default defineConfig({ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), }, + // The install-local uninstall door reads a package's declared catalog + // items with `declaredCatalogItemsOf` (C3 stage 8) — a VALUE import of + // `@objectstack/metadata-protocol`, so its suites must run this checkout's + // source, not a `metadata-protocol/dist` that may be behind it. Anchored, + // same reason as the entries above. + { + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 04717d4eb9d..896c9e2d156 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -112,7 +112,7 @@ export const ADMIN_STANDING_SURFACE: Readonly + '(`readDisabledCatalogNames`, §6a): a `permission` row of `admin_full_access` whose `active` ' + 'is false un-makes every GRANT-derived platform administrator at once (§6b), with no identity ' + 'table touched; a `position` row switches that position off. Deployment-wide (no tenant ' - + 'column). The `sys_permission_set` and `sys_position` rows are no longer read at all: the ' + + 'column). No catalog row is read — the row objects are retired (ADR-0131 D3, C3 stage 8): the ' + 'set itself — its existence and its body — is the catalog definition (see the ' + '`security-catalog` non-table input). ⚠️ It does NOT un-make a CONFIG-derived administrator ' + '(§6b-config), which never reads a set.', diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index c73c21c12ed..5099a25402b 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -239,11 +239,6 @@ export { // ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate. export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js'; -// ADR-0049 enforce-or-remove — the `active` flag on the RBAC grant catalogues -// (`sys_permission_set` / `sys_position`). One predicate for the resolver that -// enforces it and the break-glass guard that simulates a write to it. -export { isRowActive, type ActivatableRow } from './row-active.js'; - // ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions, // permission sets, capabilities) over the engine registry and the metadata // service. It says a definition EXISTS under a name, never that it is in diff --git a/packages/core/src/security/resolve-user-grants-cache.ts b/packages/core/src/security/resolve-user-grants-cache.ts index aead9a48791..fe6006cc34d 100644 --- a/packages/core/src/security/resolve-user-grants-cache.ts +++ b/packages/core/src/security/resolve-user-grants-cache.ts @@ -118,9 +118,6 @@ export const GRANTS_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set([ 'sys_member', 'sys_user_position', 'sys_user_permission_set', - 'sys_position', - 'sys_position_permission_set', - 'sys_permission_set', 'sys_user', // [ADR-0131 D3, ADR-0126 §4] The activation ledger: a position or permission // set switched off there stops granting (`readDisabledCatalogNames`). diff --git a/packages/core/src/security/row-active.ts b/packages/core/src/security/row-active.ts deleted file mode 100644 index 2f46c79e7af..00000000000 --- a/packages/core/src/security/row-active.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The `active` flag on the RBAC grant catalogues — `sys_permission_set` and - * `sys_position` (ADR-0049 enforce-or-remove). - * - * Both objects ship a Deactivate action whose confirmation dialog promises, in - * four locales, that access stops: - * - * > Deactivate this permission set? Existing assignments stay in place but stop - * > granting access until re-activated. - * > Deactivate this position? Users keep their assignment but the position stops - * > granting permissions until re-activated. - * - * ⚠️ The catalogue row's `active` column DECIDES NO GRANT any more (ADR-0131 - * D3, ADR-0126 §4). `resolveUserAuthzGrants` reads deactivation for both types - * from the deployment-level activation ledger, `sys_metadata_activation` - * (`metadata_type` `position` / `permission`), written through - * `POST /api/v1/security/_activation/:type/:name`; it never calls this - * predicate. The Deactivate dialog's promise is kept by that ledger row, not by - * this column. - * - * What still reads the column through this predicate, on `main`: the explain - * engine's "deactivated" label (`plugin-security` `explain-engine.ts`) and the - * security plugin's set loader (`plugin-security` `security-plugin.ts`). The - * last-administrator guard and the sharing-rule position check read the ledger - * instead. The reasoning below is about reading the column at all, and holds - * for each remaining reader. - * - * ## Why the predicate is "explicitly deactivated", not "explicitly active" - * - * Absent means ACTIVE. Only a stored value that really reads false takes the - * grant away. Two measured reasons, and they point the same way: - * - * 1. **Deployed data.** `active` carries `defaultValue: true`, but a row that - * predates the column, arrived through a migration, or was projected with a - * `fields` list that omitted it carries no value at all. Requiring `true` - * would revoke every such row's grants the moment this lands — a silent - * mass revocation nobody asked for, which is the opposite of the one thing - * the Deactivate dialog promises. `isGrantActive` reads absent bounds as - * unbounded for exactly this reason (ADR-0091 D2). - * 2. **Storage shapes.** SQLite stores booleans as 1/0 and presents them back - * as numbers unless the driver knows the column is boolean; the memory - * driver round-trips real booleans; a JSON/text column can hand back - * `'false'`. `row.active === false` alone therefore misses the deactivated - * row on the primary driver — an enforcement hole shaped exactly like the - * bug this predicate closes. Pushing `active: true` into a driver `where` - * has the same problem from the other side AND drops the NULL rows of (1), - * so the filter runs in memory over rows the resolver already fetched: - * zero new queries, identical answer on every driver. - * - * The false-set below is closed on purpose. An unrecognised value (a stray - * string, an object) is NOT a deactivation — nothing writes one, and inventing - * a revocation out of junk data is the failing-open-into-a-lockout direction. - */ - -/** The stored spellings of a deactivated row, across every driver in the tree. */ -const DEACTIVATED_VALUES: readonly unknown[] = [false, 0, '0', 'false']; - -/** A catalogue row that may carry the `active` flag (`sys_permission_set`, `sys_position`). */ -export interface ActivatableRow { - active?: unknown; -} - -/** - * True unless the row carries an `active` column that is explicitly OFF. - * - * The ONE predicate every reader of `sys_permission_set.active` / - * `sys_position.active` uses (the readers are listed in the module doc), so - * they can never disagree about what a deactivated ROW means. It is not the - * grant switch: the resolver reads the activation ledger instead. - */ -export function isRowActive(row: ActivatableRow | null | undefined): boolean { - if (!row) return false; - const value = (row as { active?: unknown }).active; - if (value === undefined || value === null) return true; - return !DEACTIVATED_VALUES.includes(value); -} diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 55ebf61653b..85345d138c6 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3078,12 +3078,12 @@ const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = { 'A `requiredPermissions` entry names a capability the caller must hold. It is a free string, so ' + 'a typo (`mange_users` for `manage_users`) parses and fails CLOSED at run time: the gate denies ' + 'its callers, the safe direction, and nothing tells the author the capability exists nowhere.', - // The four sources are held to the rule by its test, each one silencing it. + // The three sources are held to the rule by its test, each one silencing it. 'A capability is known when it is a built-in platform capability, when the stack declares it ' + - 'with `defineCapability` (`stack.capabilities`, ADR-0066 D1), when a permission set in the ' + - 'stack grants it through `systemPermissions` (granting declares it), or when a ' + - '`sys_capability` seed row ships it. `systemPermissions` itself is never reported: it is the ' + - 'declaration side.', + 'with `defineCapability` (`stack.capabilities`, ADR-0066 D1), or when a permission set in the ' + + 'stack grants it through `systemPermissions` (granting declares it). A seed row is no source: ' + + 'no runtime reader consults one, so ship the capability with `defineCapability` instead. ' + + '`systemPermissions` itself is never reported: it is the declaration side.', // The positions are held to the rule by its test, each fired. 'Judged at every position the key is declared: an object\'s `requiredPermissions` (the array ' + 'form, or each operation of the per-operation map), each field\'s, each object action\'s and ' + diff --git a/packages/lint/src/validate-capability-references.seed-row.test.ts b/packages/lint/src/validate-capability-references.seed-row.test.ts new file mode 100644 index 00000000000..f2b3f8604fa --- /dev/null +++ b/packages/lint/src/validate-capability-references.seed-row.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { validateCapabilityReferences, CAPABILITY_REFERENCE_UNKNOWN } from './validate-capability-references.js'; + +// [ADR-0131 D3, C3 stage 8] A `sys_capability` seed row declares nothing: no +// runtime reader consults one, and the row object is retired. Counting it as a +// source silenced this warning for a capability the runtime never declares, +// while the `requiredPermissions` gate it names failed closed. +describe('capability-reference-unknown — a seed row is no source', () => { + it('still warns on a capability only a sys_capability seed row ships', () => { + const findings = validateCapabilityReferences({ + data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], + }); + expect(findings.map((f) => f.rule)).toEqual([CAPABILITY_REFERENCE_UNKNOWN]); + expect(findings[0].hint).not.toContain('seed row'); + }); + + it('CONTROL: the same capability declared with defineCapability is known', () => { + const findings = validateCapabilityReferences({ + capabilities: [{ name: 'approve_invoice', label: 'Approve', scope: 'org' }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index d6f41108502..c8224e3d824 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -19,8 +19,12 @@ * (`stack.capabilities`) — the explicit, package-provenanced declaration * (ADR-0066 D1), served at runtime by the registry (ADR-0131 D3), * 3. every capability a permission set in this stack GRANTS via - * `systemPermissions` (granting a capability also declares it), and - * 4. any `sys_capability` row shipped as seed data. + * `systemPermissions` (granting a capability also declares it). + * + * A `sys_capability` seed row is NOT a source: no runtime reader consults one + * (ADR-0131 D3 — the catalog is the registry, and the row object is retired), + * so counting it would silence this warning for a capability the runtime never + * declares. * * WARNING, not error: a single package's lint cannot see capabilities declared * by OTHER installed packages, and the reference fails closed at runtime anyway, @@ -93,17 +97,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin for (const ps of recordsOf(stack.permissions)) { for (const cap of asCapArray(ps.systemPermissions)) known.add(cap); } - for (const seed of recordsOf(stack.data)) { - if (seed.object !== 'sys_capability') continue; - for (const rec of Array.isArray(seed.records) ? seed.records : []) { - const name = (rec as AnyRec | null)?.name; - if (typeof name === 'string' && name.length > 0) known.add(name); - } - } const hint = 'Fix the capability name, define it with defineCapability (stack.capabilities), ' + - 'declare it on a permission set’s systemPermissions, ship a sys_capability seed row, ' + + 'declare it on a permission set’s systemPermissions, ' + 'or ignore this if the capability is provided by another installed package ' + '(references fail closed at runtime).'; @@ -114,12 +111,12 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin rule: CAPABILITY_REFERENCE_UNKNOWN, where, path, - // [#22161] One verdict sentence; the four sources of the known set, the + // [#22161] One verdict sentence; the three sources of the known set, the // positions walked and why it only warns is // `os explain capability-reference-unknown`. message: - `requiredPermissions names capability "${cap}", which no built-in, \`defineCapability\`, ` + - `\`systemPermissions\` grant or \`sys_capability\` seed provides, so this gate fails closed`, + `requiredPermissions names capability "${cap}", which no built-in, \`defineCapability\` ` + + `or \`systemPermissions\` grant provides, so this gate fails closed`, hint, }); }; diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index e2a289d4a31..1b195bb0897 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -118,7 +118,8 @@ export type { SeedTenancySplit, SeedTenancyCollision, } from './migrations/seed-tenancy-backfill.js'; -export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; +export type { UninstallCleanup, UninstallCleanupOutcome, UninstalledCatalogItem } from './protocol.js'; +export { declaredCatalogItemsOf } from './protocol.js'; // [#9960] The ONE declared shape of the `deletePackage` seam, exported so the // two consumers that speak it (`@objectstack/rest`'s direct-mount package // registrar and the `@objectstack/runtime` dispatcher twin) type the seam diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index a0fda00025d..9d3236b56a6 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -195,7 +195,7 @@ export const PACKAGED_BASE_REGIME: Readonly; }) => Promise<{ success: boolean; removed: number; error?: string }>; +/** One security-catalog item an uninstalled package declared (ADR-0131 D3). */ +export interface UninstalledCatalogItem { + type: 'position' | 'permission' | 'capability'; + name: string; +} + +const UNINSTALLED_CATALOG_COLLECTIONS: ReadonlyArray<[UninstalledCatalogItem['type'], string]> = [ + ['position', 'positions'], ['permission', 'permissions'], ['capability', 'capabilities'], +]; + +/** + * The security-catalog items a package declares: its manifest's (and its + * bundled plugins') `positions` / `permissions` / `capabilities` arrays, plus + * any of its stored `sys_metadata` rows of those types. Read by an uninstalling + * door before it withdraws the package, for {@link UninstallCleanup}'s + * `declared`. A non-array `permissions` is the ADR-0025 capability grant, not a + * list of sets, and is skipped. + */ +export function declaredCatalogItemsOf( + manifest: unknown, + rows: ReadonlyArray<{ type?: unknown; name?: unknown }> = [], +): UninstalledCatalogItem[] { + const out = new Map(); + const add = (type: UninstalledCatalogItem['type'], name: unknown) => { + if (typeof name === 'string' && name !== '') out.set(`${type}|${name}`, { type, name }); + }; + const read = (source: unknown) => { + if (!source || typeof source !== 'object') return; + for (const [type, key] of UNINSTALLED_CATALOG_COLLECTIONS) { + const items = (source as Record)[key]; + if (Array.isArray(items)) for (const item of items) add(type, (item as { name?: unknown } | null)?.name); + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + for (const row of rows) { + const type = canonicalMetaType(String(row?.type ?? '')); + if (type === 'position' || type === 'permission' || type === 'capability') add(type, row?.name); + } + return [...out.values()]; +} + /** Per-cleanup outcome reported on the `deletePackage` response. */ export interface UninstallCleanupOutcome { name: string; @@ -5950,7 +6002,10 @@ export class ObjectStackProtocolImplementation implements * does — and never swallows it. */ async runUninstallCleanups( - request: Pick, + request: Pick & { + /** The package's catalog items, read before its withdrawal ({@link declaredCatalogItemsOf}). */ + declared?: ReadonlyArray; + }, ): Promise { const cleanups: UninstallCleanupOutcome[] = []; for (const [name, cleanup] of this.uninstallCleanups) { @@ -5958,6 +6013,7 @@ export class ObjectStackProtocolImplementation implements const r = await cleanup({ packageId: request.packageId, ...(request.actor ? { actor: request.actor } : {}), + ...(request.declared ? { declared: request.declared } : {}), }); cleanups.push({ name, @@ -23846,6 +23902,17 @@ export class ObjectStackProtocolImplementation implements packageRegistry.assertPackageUninstallable(request.packageId); } + // [ADR-0131 D3, C3 stage 8] What the package declared in the security + // catalog, read now: the withdrawal below takes it out of the registry + // and the per-item deletes take its rows, so the cleanups that revoke + // grants by name could not read it afterwards. + let declared: UninstalledCatalogItem[] = []; + try { + declared = declaredCatalogItemsOf(packageRegistry?.getPackage?.(request.packageId)?.manifest, rows); + } catch { + declared = declaredCatalogItemsOf(undefined, rows); + } + // [#21276] THE STORE DELETE COMES FIRST, and its refusal is this verb's // refusal. Triage's ruling: refuse before withdrawing, not undo. Every // step above this one only reads; every step below it — the per-item @@ -23984,7 +24051,7 @@ export class ObjectStackProtocolImplementation implements // a security condition) is visible to the caller, never silent. // [#21490] Through the registry's one runner, which install-local's // uninstall door calls too. - const cleanups = await this.runUninstallCleanups(request); + const cleanups = await this.runUninstallCleanups({ ...request, declared }); return { success: failed.length === 0 && deleted.length > 0, diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts index 622c7619fe8..c63d1b954f5 100644 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -204,22 +204,6 @@ export const PLATFORM_OBJECT_TENANCY: Readonly = { }, description: { label: "Description", - helpText: "Human-readable description shown in Setup (persisted as sys_permission_set.description)." + helpText: "Human-readable description shown in Setup." }, isDefault: { label: "Is Default", diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 3220450c6ea..6d68e68febe 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -280,6 +280,5 @@ export const en: TranslationData = { // mirrors the literal in the page metadata, as above. sys_user_detail: { label: 'User' }, sys_organization_detail: { label: 'Organization' }, - sys_position_detail: { label: 'Position' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 1ce68c098bd..ad11059ab7e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2306,7 +2306,7 @@ export const esESMetadataForms: NonNullable = }, description: { label: "Descripción", - helpText: "Descripción legible que se muestra en Setup (se guarda como sys_permission_set.description)." + helpText: "Descripción legible que se muestra en Setup." }, isDefault: { label: "Predeterminado", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index bb48dc87b67..9f763fbb114 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -184,5 +184,4 @@ export const esESSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 16de6d4e247..e5f300a5e38 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -190,6 +190,5 @@ export const esES: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: 'Usuario' }, sys_organization_detail: { label: 'Organización' }, - sys_position_detail: { label: 'Posición' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 162e66518f5..89f028cb0d3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2306,7 +2306,7 @@ export const jaJPMetadataForms: NonNullable = }, description: { label: "説明", - helpText: "Setup に表示される説明文(sys_permission_set.description として保存されます)。" + helpText: "Setup に表示される説明文。" }, isDefault: { label: "既定の権限セット", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index d3063192df3..14a7015babb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -184,5 +184,4 @@ export const jaJPSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 07eb8248660..5acbe5e69b9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -188,6 +188,5 @@ export const jaJP: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: 'ユーザー' }, sys_organization_detail: { label: '組織' }, - sys_position_detail: { label: 'ポジション' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 47464261675..b37a32db9a2 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2306,7 +2306,7 @@ export const zhCNMetadataForms: NonNullable = }, description: { label: "描述", - helpText: "在 Setup 中展示的可读描述(持久化为 sys_permission_set.description)。" + helpText: "在 Setup 中展示的可读描述。" }, isDefault: { label: "默认权限集", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 5229f05412a..b2ddf8c3661 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -184,5 +184,4 @@ export const zhCNSourceHashes: Readonly> = { 'pages.connect_agent.subtitle': '2d0f9a000824e78a', 'pages.sys_user_detail.label': '6c0d22a931e235bd', 'pages.sys_organization_detail.label': '3e55836156e1c1de', - 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index ba01a9eb04f..98ed30cb272 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -196,6 +196,5 @@ export const zhCN: TranslationData = { // Platform record pages (#15743) -- see `en.ts` for the key set. sys_user_detail: { label: '用户' }, sys_organization_detail: { label: '组织' }, - sys_position_detail: { label: '岗位' }, }, }; diff --git a/packages/platform-objects/src/pages/index.ts b/packages/platform-objects/src/pages/index.ts index 702841fc1a1..459be24c2a7 100644 --- a/packages/platform-objects/src/pages/index.ts +++ b/packages/platform-objects/src/pages/index.ts @@ -2,4 +2,3 @@ export { SysOrganizationDetailPage } from './sys-organization.page.js'; export { SysUserDetailPage } from './sys-user.page.js'; -export { SysPositionDetailPage } from './sys-position.page.js'; diff --git a/packages/platform-objects/src/pages/sys-position.page.ts b/packages/platform-objects/src/pages/sys-position.page.ts deleted file mode 100644 index 0a9db276f11..00000000000 --- a/packages/platform-objects/src/pages/sys-position.page.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Page } from '@objectstack/spec/ui'; - -/** - * sys_position — Record Detail Page (slotted, default for ALL sys_position - * records) - * - * **Audience**: admins managing capability distribution from Setup. - * - * A position (岗位, ADR-0090 D3) is the flat distribution group: users hold - * it (`sys_user_position`), and it binds permission sets - * (`sys_position_permission_set`). This page makes both edges manageable - * as PURE SDUI — two `record:related_list` tabs with Add pickers; no - * bespoke React: - * - * - **Holders** — who holds this position. `sys_user_position.position` - * stores the position's MACHINE NAME, so the list keys on - * `relationshipValueField: 'name'` (the generic name-keyed-junction - * affordance this page motivated). Assigning here inserts an ordinary - * assignment row; the ADR-0090 D12 delegated-admin gate governs the - * write and its denial reason surfaces in the Add dialog. The - * `business_unit_id` column is the assignment-level BU anchor - * (ADR-0090 Addendum); `valid_from`/`valid_until` columns join it when - * ADR-0091 L1 lands. - * - **Permission Sets** — what the position distributes - * (`sys_position_permission_set`, ordinary id-keyed junction). Binding - * is guarded by BOTH the audience-anchor gate (D5/D9 — high-privilege - * sets never bind to everyone/guest) and the D12 gate. - * - * Header/actions fall through to the synthesizer, so the object's declared - * row actions (activate/deactivate/clone/set-default) keep working. - */ -export const SysPositionDetailPage: Page = { - name: 'sys_position_detail', - label: 'Position', - type: 'record', - object: 'sys_position', - template: 'default', - kind: 'slotted', - isDefault: true, - - regions: [], - - slots: { - tabs: { - type: 'page:tabs', - properties: { - // `tabStyle`, not `type` (#6776) — see sys-organization.page.ts. - tabStyle: 'line', - position: 'top', - items: [ - { - label: { en: 'Holders', 'zh-CN': '任职人员', 'ja-JP': '在任者', 'es-ES': 'Titulares' }, - icon: 'users', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_user_position', - relationshipField: 'position', - relationshipValueField: 'name', - columns: ['user_id', 'business_unit_id', 'granted_by', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Holders', 'zh-CN': '任职人员', 'ja-JP': '在任者', 'es-ES': 'Titulares' }, - add: { - picker: { - object: 'sys_user', - labelField: 'name', - }, - linkField: 'user_id', - label: { en: 'Assign user', 'zh-CN': '分配用户', 'ja-JP': 'ユーザーを割り当て', 'es-ES': 'Asignar usuario' }, - }, - }, - }, - ], - }, - { - label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - icon: 'lock', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'sys_position_permission_set', - relationshipField: 'position_id', - columns: ['permission_set_id', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - limit: 25, - showViewAll: true, - title: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos' }, - add: { - picker: { - object: 'sys_permission_set', - labelField: 'label', - }, - linkField: 'permission_set_id', - label: { en: 'Bind permission set', 'zh-CN': '绑定权限集', 'ja-JP': '権限セットをバインド', 'es-ES': 'Vincular conjunto de permisos' }, - }, - }, - }, - ], - }, - ], - }, - }, - - // No Chatter feed on an RBAC primitive. - discussion: [], - }, -}; diff --git a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts index 0a49255aa80..994f4ec530b 100644 --- a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts @@ -37,11 +37,12 @@ */ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; -// SysCapability carries curated translations already present in the bundles; it -// must stay in this list so `os i18n extract` keeps emitting it (dropping it -// here silently deletes those strings on the next run — the sys_audit_log -// incident). Enforced by src/translations/bundle-ownership.test.ts. -import { SysPosition, SysCapability, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition } from '../src/objects/index.js'; +// Every object the plugin owns must stay in this list so `os i18n extract` +// keeps emitting it (dropping one here silently deletes its strings on the next +// run — the sys_audit_log incident). Enforced by +// src/translations/bundle-ownership.test.ts. [C3 stage 8] The four catalog row +// objects are retired (ADR-0131 D3), and their strings with them. +import { SysUserPermissionSet, SysUserPosition } from '../src/objects/index.js'; import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -62,7 +63,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; * precision: the annotated type is the function's own return type. */ const config: ObjectStackDefinition = defineStack({ - objects: [SysPosition, SysCapability, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition] as any, + objects: [SysUserPermissionSet, SysUserPosition] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index edf2a164d98..56166db7690 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -76,7 +76,6 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -450,6 +449,12 @@ export function shouldReplayBootstrapFor(opCtx: { */ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; +/** Whether a grant still carries the legacy id a release before the name column wrote (ADR-0131 D10). */ +function hasLegacySetId(row: any): boolean { + const id = row?.permission_set_id; + return id !== undefined && id !== null && String(id) !== ''; +} + /** * Who ALREADY holds the unscoped platform-admin grant (commit 1c83ca226) — the * one rule both {@link bootstrapPlatformAdmin}'s `already_have_admin` guard and @@ -528,20 +533,26 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * nothing yet, and a by-name scan alone would answer "nobody", promote the * oldest user and hand them the seeded records: the second grant this function * exists to refuse. So leg C, only when A and B found nobody, reaches the - * grants that name NOTHING through the set row's id, and an unscoped human one - * comes back as `unnamedHolder`. It only ever RESTRICTS: the bootstrap - * withholds the promotion on it, and it is never a holder — never an - * `adminUserId`, never a claim target ({@link findExistingPlatformAdmin}). - * Leg C runs only when the deployment still carries a legacy `admin_full_access` - * row (`adminPsId`): a database with no such row has no grant written before - * the name column existed, and the by-name legs answer alone. + * unscoped human grants that name NOTHING and still carry a legacy + * `permission_set_id`, and one comes back as `unnamedHolder`. It only ever + * RESTRICTS: the bootstrap withholds the promotion on it, and it is never a + * holder — never an `adminUserId`, never a claim target + * ({@link findExistingPlatformAdmin}). + * + * [C3 stage 8] Leg C asks for ANY such grant, not for grants of the admin set's + * row: the catalog row object is retired, so no read can say which set a legacy + * id pointed at. An unnamed grant of another set therefore withholds the + * promotion too, until the organization-ownership migration's verified + * id-to-name rewrite (ADR-0131 D10) names it — the stricter reading, since the + * one it cannot tell apart may be the administrator's. A database that holds no + * unnamed grant (every fresh one) answers by name alone. */ async function findPlatformAdminGrantHolder( ql: any, - adminPsId: string | undefined, + reachUnnamed: boolean, ): Promise<{ holder: any | undefined; - /** Leg C: an unscoped human grant of the admin set row that names no set yet (restricts only). */ + /** Leg C: an unscoped human grant that names no set yet and carries a legacy id (restricts only). */ unnamedHolder: any | undefined; adminGrantRowsExamined: number; truncated: boolean; @@ -599,13 +610,13 @@ async function findPlatformAdminGrantHolder( holder = await scan({ [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME }, isUnscopedHumanHolder); } - // Leg C — the grants that name nothing yet, through the set row's id; it + // Leg C — the grants that name nothing yet and carry a legacy id; it // restricts only (see the function note). let unnamedHolder: any | undefined; - if (!holder && adminPsId) { + if (!holder && reachUnnamed) { unnamedHolder = await scan( - { permission_set_id: adminPsId }, - (r) => grantSetNameOf(r) === undefined && isUnscopedHumanHolder(r), + { [GRANT_SET_NAME_FIELD]: null }, + (r) => grantSetNameOf(r) === undefined && hasLegacySetId(r) && isUnscopedHumanHolder(r), ); } @@ -650,7 +661,7 @@ export async function findExistingPlatformAdmin( } // [ADR-0131 D4] By name alone: leg C (the legacy id reach) only restricts // and never names a holder, so this question needs no catalog row. - const { holder } = await findPlatformAdminGrantHolder(ql, undefined); + const { holder } = await findPlatformAdminGrantHolder(ql, false); return holder?.user_id ? String(holder.user_id) : undefined; } @@ -709,19 +720,12 @@ export async function bootstrapPlatformAdmin( return { adminPromoted: false, reason: 'objectql_unavailable' }; } - // 1. [ADR-0131 D3/D4] No catalog row is written: the platform's default - // sets are definitions this plugin ships on its manifest, served from - // the registry, and a grant names its set. The one read left is the - // LEGACY `admin_full_access` row an older release seeded, whose id is how - // leg C of {@link findPlatformAdminGrantHolder} reaches a grant written - // before the name column existed. ⛔ "Refused" is not "absent": a read - // that did not answer cannot say no such grant exists, so the promotion - // is withheld below rather than risk minting a second administrator. - let legacyAdminRowUnreadable = false; - const legacyAdminRows = await tryFind( - ql, 'sys_permission_set', { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, 1, undefined, undefined, - () => { legacyAdminRowUnreadable = true; }, - ); + // 1. [ADR-0131 D3/D4] No catalog row is written or read: the platform's + // default sets are definitions this plugin ships on its manifest, served + // from the registry, and a grant names its set. [C3 stage 8] The catalog + // row object is retired, so a grant written before the name column + // existed is reached by leg C of {@link findPlatformAdminGrantHolder} + // through the grant table alone. // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): @@ -749,23 +753,6 @@ export async function bootstrapPlatformAdmin( if (!bootstrapPermissionSets.some((ps) => ps.name === PLATFORM_ADMIN_PERMISSION_SET_NAME)) { return { adminPromoted: false, reason: 'admin_permission_set_missing' }; } - if (legacyAdminRowUnreadable) { - // [#22169] The set's row table did not answer, so the legacy leg cannot ask - // whether an unnamed grant already makes someone the administrator; no - // promotion is made on a question this pass could not ask. Said through - // the sink derivation, so it is heard with no logger injected. - reportThroughSink( - logger, - '[security] platform-admin promotion WITHHELD: the legacy admin_full_access sys_permission_set row ' + - 'could not be read, so this pass cannot ask whether a grant written before the name column existed ' + - 'already makes someone the platform administrator. Nothing was written; the next pass whose read ' + - 'answers decides.', - { name: PLATFORM_ADMIN_PERMISSION_SET_NAME }, - ); - return { adminPromoted: false, reason: 'admin_permission_set_unreadable' }; - } - const adminPsId: string | undefined = - typeof legacyAdminRows[0]?.id === 'string' && legacyAdminRows[0].id !== '' ? legacyAdminRows[0].id : undefined; // ── Does this deployment ALREADY have a platform admin? (commit 1c83ca226) ─ // @@ -778,7 +765,7 @@ export async function bootstrapPlatformAdmin( unnamedHolder, adminGrantRowsExamined, truncated: adminGrantScanTruncated, - } = await findPlatformAdminGrantHolder(ql, adminPsId); + } = await findPlatformAdminGrantHolder(ql, true); // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and @@ -821,20 +808,20 @@ export async function bootstrapPlatformAdmin( }; } - // [ADR-0131 D4] An unscoped grant of the admin set row that names no set yet - // — on an upgraded deployment's first boot, its platform administrator's - // grant, which the name backfill reaches only at `kernel:bootstrapped`. It is - // read as an administrator who may already exist: promoting now would mint - // the second grant {@link findPlatformAdminGrantHolder} exists to refuse. It - // names no `adminUserId` — it restricts, it confers nothing — so the seed - // claim waits for a boot that reads the grant by its name. + // [ADR-0131 D4] An unscoped grant that names no set yet — on an upgraded + // deployment, possibly its platform administrator's grant, written before + // the name column existed. It is read as an administrator who may already + // exist: promoting now could mint the second grant + // {@link findPlatformAdminGrantHolder} exists to refuse. It names no + // `adminUserId` — it restricts, it confers nothing — so the seed claim waits + // for a boot that reads the grant by its name. if (!walled && unnamedHolder) { const withheld = - '[security] platform-admin promotion WITHHELD: an unscoped admin_full_access grant exists whose ' - + 'permission-set name (sys_user_permission_set.permission_set) is not written yet, so this pass ' - + 'cannot read it as the holder and will not mint a second one. The one-time grant-name backfill ' - + 'names it at kernel:bootstrapped; the next pass reads it by name. If it stays unnamed, the ' - + 'backfill\'s own report says why.'; + '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + + 'so this pass will not mint a second one. The organization-ownership migration\'s id-to-name rewrite ' + + '(ADR-0131 D10) names it; the next pass after that reads it by name.'; if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts index 0d9d2eb35e8..39ba6a5f98c 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.ts @@ -5,20 +5,21 @@ * data-plane permission rows (ADR-0086 D3, #2747). * * ADR-0090 D5 promises: "uninstalling the package (removing its sets by - * `packageId`) revokes it everywhere at once. No ghost grants." The - * `package_id`/`managed_by` provenance columns (and the `package_id` index on - * `sys_permission_set`) exist precisely for this query; this module is the - * wiring that consumes them. Registered with the protocol's uninstall-cleanup - * seam (the mirror of the publish materializer) so `deletePackage` triggers it - * without the protocol layer learning `sys_permission_set`'s shape. + * `packageId`) revokes it everywhere at once. No ghost grants." Registered + * with the protocol's uninstall-cleanup seam so `deletePackage` (and + * install-local's uninstall door) trigger it without the protocol layer + * learning the grant table's shape. * - * Scope — provenance rules identical to the seeder's (ADR-0086 D4): - * - ONLY rows `managed_by: 'package'` with `package_id` = the uninstalled - * package are touched. Env-authored sets (`platform`/`user`/absent) and - * other packages' sets are never removed, even on a name collision. - * - Bindings referencing a removed set (`sys_position_permission_set`, - * `sys_user_permission_set`) are deleted first, so no dangling grant rows - * survive and re-resolution never sees a half-removed state. + * Scope: + * - [ADR-0131 D3/D4, C3 stage 8] The package's permission sets are the names + * it declared in the security catalog. The uninstalling door reads them off + * the package before it withdraws it (`declared` on the cleanup's + * arguments), and every grant naming one of them is revoked. No catalog row + * is read: the row objects are retired, and the package withdrawal already + * took the definitions themselves out of the catalog. A grant written + * before the name column existed names nothing, so it cannot be reached + * here; the organization-ownership migration's id-to-name rewrite + * (ADR-0131 D10) owns it. * - `sys_audience_binding_suggestion` rows for the package are removed in * every status: with the sets gone, confirmed/dismissed history points at * nothing, and a fresh reinstall should re-prompt (D5 — admin confirms). @@ -82,11 +83,11 @@ async function deleteRows( } export interface PackagePermissionCleanupOutcome { - /** Package-owned sys_permission_set rows removed. */ + /** Always 0 since C3 stage 8: a permission set is a catalog definition, and no row is removed. */ sets: number; - /** sys_position_permission_set rows referencing those sets. */ + /** Always 0 since C3 stage 8: a position's sets are part of its definition, and no binding row exists. */ positionBindings: number; - /** sys_user_permission_set rows referencing those sets. */ + /** sys_user_permission_set rows naming those sets. */ userGrants: number; /** sys_audience_binding_suggestion rows for the package (any status). */ suggestions: number; @@ -110,6 +111,8 @@ export async function cleanupPackagePermissions( ql: any, packageId: string, logger?: { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }, + /** The permission-set names the package declared, read off it before its withdrawal. */ + declaredSetNames: readonly string[] = [], ): Promise { const out: PackagePermissionCleanupOutcome = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0, @@ -122,34 +125,16 @@ export async function cleanupPackagePermissions( // consequence. const refusals = createSeedWriteRefusals(); - // Provenance-scoped: only the package door's own rows (ADR-0086 D4). - // `managed_by` is filtered in JS — a multi-column where on the readonly - // provenance columns doesn't match through the engine's query layer - // (verified empirically), while the single-column package_id filter does. - const sets = (await tryFind(ql, 'sys_permission_set', { package_id: packageId })) - .filter((r) => r?.managed_by === 'package'); - - // Bindings first — a set row must never outlive its grants in reverse. - for (const set of sets) { - if (!set?.id) continue; - out.positionBindings += await deleteRows( - ql, 'sys_position_permission_set', - await tryFind(ql, 'sys_position_permission_set', { permission_set_id: set.id }), + // [ADR-0131 D4] A grant names its set BY NAME, so the grants of a set the + // package declared are the rows naming it. + const names = [...new Set(declaredSetNames.filter((n) => typeof n === 'string' && n !== ''))]; + for (const name of names) { + out.userGrants += await deleteRows( + ql, 'sys_user_permission_set', + await tryFind(ql, 'sys_user_permission_set', { permission_set: name }), refusals, ); - // [ADR-0131 D4] A grant names its set BY NAME, so the grants of this set - // are the rows naming it; a grant written before the name column existed - // is still reached through its legacy id, to revoke only. - const grants = new Map(); - const byName = typeof set.name === 'string' && set.name !== '' - ? await tryFind(ql, 'sys_user_permission_set', { permission_set: set.name }) - : []; - for (const g of [...byName, ...(await tryFind(ql, 'sys_user_permission_set', { permission_set_id: set.id }))]) { - if (g?.id && !grants.has(String(g.id))) grants.set(String(g.id), g); - } - out.userGrants += await deleteRows(ql, 'sys_user_permission_set', [...grants.values()], refusals); } - out.sets = await deleteRows(ql, 'sys_permission_set', sets, refusals); // Suggestion rows in every status — reinstall re-prompts fresh (D5). out.suggestions = await deleteRows( @@ -185,20 +170,19 @@ export async function cleanupPackagePermissions( `package — those rows SURVIVE the uninstall, and nothing else reports it: the package door ` + `has already answered "success" and the count it reported is a count of the rows that were ` + `actually removed. ADR-0090 D5 promises that removing a package's sets "revokes it ` + - `everywhere at once. No ghost grants." Where a sys_position_permission_set or ` + - `sys_user_permission_set row survived, that promise is BROKEN and the grant is still live: ` + - `every principal holding it keeps the uninstalled package's permissions until the row goes. ` + - `Where a sys_permission_set row survived, the set remains resolvable by name and a reinstall ` + - `collides with it. What the store actually said is in the query engine's "Delete operation ` + + `everywhere at once. No ghost grants." Where a sys_user_permission_set row survived, that ` + + `promise is BROKEN and the grant is still live should the name ever resolve again: a reinstall, ` + + `or another holder of the same name, hands its permissions straight back to every principal ` + + `holding it. What the store actually said is in the query engine's "Delete operation ` + `failed" entries logged just before this one. Remedy: re-run the uninstall once the store ` + - `accepts writes again — this sweep is idempotent and re-selects by package_id, so a second ` + + `accepts writes again — this sweep is idempotent and re-selects by name, so a second ` + `pass removes whatever the first could not. Until then, treat the package as still ` + `installed for authorization purposes.`, { packageId, refused: out.refused, refusals: refusals.report() }, ); } - if (out.sets + out.positionBindings + out.userGrants + out.suggestions > 0) { - logger?.info?.('[security] package permission rows revoked on uninstall — removed by package_id, so no grant outlives the package (ADR-0090 D5)', { + if (out.userGrants + out.suggestions > 0) { + logger?.info?.('[security] package permission rows revoked on uninstall — every grant naming a set the package declared, so no grant outlives the package (ADR-0090 D5)', { packageId, ...out, }); } diff --git a/packages/plugins/plugin-security/src/manifest.ts b/packages/plugins/plugin-security/src/manifest.ts index 140cb1091e0..76c6d90ed37 100644 --- a/packages/plugins/plugin-security/src/manifest.ts +++ b/packages/plugins/plugin-security/src/manifest.ts @@ -9,11 +9,7 @@ */ import { - SysPermissionSet, - SysPosition, - SysCapability, SysUserPermissionSet, - SysPositionPermissionSet, SysUserPosition, SysAudienceBindingSuggestion, defaultPermissionSets, @@ -22,13 +18,16 @@ import { export const SECURITY_PLUGIN_ID = 'com.objectstack.plugin-security'; export const SECURITY_PLUGIN_VERSION = '1.0.0'; -/** Security objects owned by plugin-security. */ +/** + * Security objects owned by plugin-security. + * + * [ADR-0131 D3] The catalog itself — positions, permission sets, capabilities + * and the position → set binding — is no object: it lives in the security + * catalog (the environment registry). What stays here are the assignments and + * grants that name catalog items, and the audience-binding suggestions. + */ export const securityObjects = [ - SysPosition, - SysCapability, - SysPermissionSet, SysUserPermissionSet, - SysPositionPermissionSet, SysUserPosition, SysAudienceBindingSuggestion, ]; diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 43611a67ec5..6872dba6fab 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -472,9 +472,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // accept, remove-member, transfer, …) rather than raw CRUD. ...denyWritesOnManagedObjects(), // RBAC tables — read-only to prevent privilege escalation. - sys_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - sys_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - sys_position_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_permission_set: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, sys_user_position: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, // [ADR-0131 D7] The compliance ledger, WITHOUT the superuser bits: the diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts index 054c188cecc..56df94508eb 100644 --- a/packages/plugins/plugin-security/src/objects/index.ts +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -9,11 +9,7 @@ * live in `@objectstack/plugin-sharing`. */ -export { SysPosition } from './sys-position.object.js'; -export { SysCapability } from './sys-capability.object.js'; -export { SysPermissionSet } from './sys-permission-set.object.js'; export { SysUserPermissionSet } from './sys-user-permission-set.object.js'; -export { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; export { SysUserPosition } from './sys-user-position.object.js'; export { SysAudienceBindingSuggestion } from './sys-audience-binding-suggestion.object.js'; export { defaultPermissionSets } from './default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts deleted file mode 100644 index 47d4ccf9c6f..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * sys_capability — Capability definition registry (ADR-0066 D1). - * - * Promotes authorization *capabilities* from bare strings to first-class - * records. A capability is layer 1 of the ADR-0066 three-way separation - * (capability / assignment / requirement): "what can be done" - * (`manage_users`, `manage_platform_settings`, `export_data`, …). The - * platform/packages DEFINE capabilities; admins EXTEND them in Setup. - * - * `PermissionSet.systemPermissions[]` (assignment) and a resource's - * `requiredPermissions[]` (requirement) reference a capability **by name** — - * so this table is the catalog/definition, NOT the grant. Existing string - * capabilities are back-compat seeded as rows with the same `name`, so all - * current references keep resolving (no migration). - * - * Named `sys_capability` (not `sys_permission` as the ADR loosely floats) to - * avoid collision with `sys_permission_set` and to match the "capability" - * vocabulary used throughout ADR-0066. - * - * @namespace sys - */ -export const SysCapability = ObjectSchema.create({ - name: 'sys_capability', - label: 'Capability', - pluralLabel: 'Capabilities', - icon: 'badge-check', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants/admins may add custom rows - // (created via UI / API) but the schema itself is locked. - protection: { - lock: 'no-overlay', - reason: 'Capability registry schema is platform-defined — see ADR-0066 / ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - // [#8535] `active` is deliberately NOT highlighted. It is a catalogue flag with - // no authorization effect (see the field's own comment), and record-header - // prominence next to `scope`/`managed_by` is itself a claim that it belongs to - // the authorization posture. Demoting it is half the fix; rewording the dialog - // is the other half — a truthful dialog under a field still presented as - // first-class tells the admin the flag matters after all. - highlightFields: ['label', 'name', 'scope', 'managed_by'], - - actions: [ - { - name: 'activate_capability', - label: 'Activate', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_capability/{id}', - bodyExtra: { active: true }, - successMessage: 'Capability activated', - refreshAfter: true, - }, - { - name: 'deactivate_capability', - label: 'Deactivate', - icon: 'circle-off', - // [#8535] Was `danger`. A danger variant is a claim in itself — it tells the - // admin the click has consequences proportionate to a security control. This - // one writes a catalogue column and nothing else. - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_capability/{id}', - bodyExtra: { active: false }, - // [#8535] This used to read: "Deactivate this capability? Grants and resource - // requirements that reference it stop resolving until re-activated." No code - // path has ever enforced that. `PermissionEvaluator.getSystemPermissions()` - // unions `permissionSets[].systemPermissions` — plain strings — and - // `requiredPermissions` is compared against that string set; neither loads a - // `sys_capability` row. The two production readers of the table are both - // seeders (`bootstrap-system-capabilities.ts`, - // `bootstrap-declared-capabilities.ts`), which WRITE `active: true` on insert - // and never read it back. - // - // The direction of that falsehood was the dangerous one: an admin withdrawing - // a capability was told in a confirmation dialog that the withdrawal took - // effect, and it silently did not — the escalation is what they believed they - // had prevented. ADR-0049 enforce-or-remove; the maintainer ruled (2026-08-13) - // that enforcement is NOT the answer here — putting the registry on the - // authorization hot path is an architectural change (caching, fail-closed - // semantics, org-authored rows influencing platform capabilities) that needs - // its own designed card if capability lifecycle management ever earns real - // pull. So the claim is withdrawn instead, and the dialog now states the - // non-effect explicitly rather than merely omitting the promise: an admin who - // remembers the old wording has to be told it was wrong, not left to infer it. - confirmText: - 'Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.', - successMessage: 'Capability deactivated', - refreshAfter: true, - }, - ], - - // [#8535] `active` was a column in ALL THREE views. It is now shown only in - // `all_capabilities` — the full-catalogue view, where a catalogue attribute - // genuinely belongs and stays observable and filterable for the admin who sets - // it. The two SCOPED views (`platform`, `org`) are the ones an admin works in - // to reason about who can do what, and a column sitting next to `managed_by` - // there reads as part of the authorization posture. Dropping it from all three - // was rejected as the opposite error: a flag the product lets you set but never - // lets you see is its own kind of dishonest surface. - listViews: { - platform: { - type: 'grid', - name: 'platform', - label: 'Platform', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by'], - filter: [{ field: 'scope', operator: 'equals', value: 'platform' }], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - org: { - type: 'grid', - name: 'org', - label: 'Organization', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'managed_by'], - filter: [{ field: 'scope', operator: 'equals', value: 'org' }], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_capabilities: { - type: 'grid', - name: 'all_capabilities', - label: 'All', - data: { provider: 'object', object: 'sys_capability' }, - columns: ['label', 'name', 'scope', 'managed_by', 'active'], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - description: 'Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access).', - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Classification ─────────────────────────────────────────── - scope: Field.select({ - label: 'Scope', - required: true, - defaultValue: 'platform', - description: 'platform = a platform-wide power; org = scoped to an organization.', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'org', label: 'Organization' }, - ], - group: 'Classification', - }), - - managed_by: Field.select({ - label: 'Managed By', - required: true, - defaultValue: 'admin', - description: 'platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'Classification', - }), - - // [ADR-0066 D1 / ADR-0086 D3] Owning package for a capability DECLARED by a - // package via `defineCapability` (absent = platform-curated or admin-created). - // Populated by bootstrapDeclaredCapabilities; makes package uninstall/upgrade - // of a capability well-defined and gives the derived-from-systemPermissions - // back-door an explicit, attributable replacement. - package_id: Field.text({ - label: 'Owning Package', - required: false, - readonly: true, - maxLength: 255, - description: 'Package that ships this capability (absent = platform-curated or admin-created).', - group: 'Classification', - }), - - // ── Status ─────────────────────────────────────────────────── - // [#8535] Catalogue flag, NOT an enforcement switch. It carried no - // `description` at all, which is how the `deactivate_capability` dialog - // became the only place its meaning was stated — and that statement was - // false. The semantics are declared here now, negative half included, so the - // field documents its own inertness at the point an author or an admin meets - // it. If capability lifecycle ever becomes enforceable it arrives as a - // designed feature with its own card (maintainer ruling, 2026-08-13), and - // this comment plus the description are what must change with it. - active: Field.boolean({ - label: 'Active', - defaultValue: true, - description: - 'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.', - group: 'Status', - }), - - // ── System ─────────────────────────────────────────────────── - id: Field.text({ - label: 'Capability ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [ADR-0120 D1, #8323] `'organization'`, NOT bare `true`. - // - // Admins EXTEND this registry in Setup (`managed_by: 'admin'`, `scope: - // 'org'`), so a capability name is one holder per organization, not one - // across the installation. Bare `true` on a DECLARED index is the - // positional spelling of `'global'` (listed columns verbatim), which made - // `name` an installation-wide key: an organization could probe whether ANY - // other organization — or the platform seed — already held a name, by - // reading 409-vs-201 on a row it has no permission to see. - // - // Platform-seeded rows carry no organization, and the organization key part - // is NULL-safe (`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so - // they remain unique among themselves. - // - // [#8470] This comment used to end "…and `bootstrapSystemCapabilities`' - // upsert-by-name is unaffected". That was WRONG, and the correction belongs - // next to the index that unmasked it. Widening the key to per-organization - // made the two-row state (platform row + an org's row, same `name`) - // REACHABLE, and the seeder's `find({ name }, limit: 1)` had no way to say - // which of the two it meant. The index is not the defect and must not be - // narrowed back — that would reinstate #8323's cross-tenant existence - // oracle. The seeder now scopes its curated lookup to `managed_by: - // 'platform'` + `organization_id: null`, i.e. exactly the bucket this key - // part keeps a singleton. - { fields: ['name'], unique: 'organization' }, - { fields: ['scope'] }, - { fields: ['active'] }, - // [ADR-0086 D3] uninstall/upgrade query: "this package's own capabilities". - { fields: ['package_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts deleted file mode 100644 index 33eff4d50b1..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ /dev/null @@ -1,364 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { P } from '@objectstack/spec'; - -/** - * sys_permission_set — System Permission Set Object - * - * Named groupings of fine-grained permissions. - * Permission sets can be bound to positions or granted directly to users - * for granular access control. - * - * @namespace sys - */ -export const SysPermissionSet = ObjectSchema.create({ - name: 'sys_permission_set', - label: 'Permission Set', - pluralLabel: 'Permission Sets', - icon: 'lock', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows - // (created via UI / API) but the schema itself is locked. - protection: { - lock: 'no-overlay', - reason: 'RBAC schema is platform-defined — see ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Named permission groupings for fine-grained access control', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - highlightFields: ['label', 'name', 'active'], - - // Custom actions — permission sets are templates bound to positions or - // users (via sys_position_permission_set / sys_user_permission_set). The - // sysadmin operations that don't live on the parent-detail tabs are - // lifecycle (activate/deactivate without losing assignments) and - // clone (build a new permset by tweaking an existing one). Both hit - // the generic data CRUD endpoint — managedBy: 'config' permits it. - actions: [ - { - name: 'activate_permission_set', - label: 'Activate', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_permission_set/{id}', - bodyExtra: { active: true }, - successMessage: 'Permission set activated', - refreshAfter: true, - }, - { - name: 'deactivate_permission_set', - label: 'Deactivate', - icon: 'circle-off', - variant: 'danger', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_permission_set/{id}', - bodyExtra: { active: false }, - confirmText: 'Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.', - successMessage: 'Permission set deactivated', - refreshAfter: true, - }, - { - // [commit 5cb62d88b] ⭐ THIS PARAMS LIST *IS* THE PAYLOAD. Every definition facet a - // clone should carry has to be named below: the action POSTs its param - // VALUES to the generic data door, so a column that is not a param is - // simply absent from the body — `permissionSetBodyFromRow()` then reads - // it through `parseMaybeJson(undefined, …)` and fills the EMPTY default - // (`[]` / `{}`). An omission here is therefore not a missing input, it is - // a silent grant loss: the clone is created, the success toast fires, and - // the difference is discoverable only by diffing the two records. - // - // That was live until commit 5cb62d88b — three of the six facets - // (`system_permissions`, `row_level_security`, `tab_permissions`) were - // never listed, so cloning a set carrying system permissions or RLS - // produced a clone with none of them. Fail-closed, and therefore quiet. - // It became urgent the moment the save door started REFUSING in-place - // edits of package-declared sets and naming THIS action as the remedy - // (`packaged-permission-set-lock.ts`, maintainer ruling 2026-08-24). - // - // `packaged-permission-set-lock.test.ts` pin 6 READS this list rather - // than restating it, so adding a facet column to the object surfaces - // here rather than silently shipping a clone that drops it. - name: 'clone_permission_set', - label: 'Clone', - icon: 'copy', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'POST', - target: '/api/v1/data/sys_permission_set', - bodyExtra: { active: true }, - // The explanatory line under the clone dialog's title. Its second - // sentence is the RULED exclusion (2026-08-24): `admin_scope` — an - // ADR-0090 D12 delegated-admin authority — is deliberately NOT copied, - // because putting one on a brand-new set on the admin's behalf is a - // privilege decision, not a field copy. It is stated HERE, where the - // admin is standing when the clone happens, because an UNEXPLAINED - // omission is the same silent drop commit 5cb62d88b fixed, merely ruled. - description: - 'Copies this set\'s permissions into a new organization-owned set you can edit. ' - + 'Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.', - successMessage: 'Permission set cloned', - refreshAfter: true, - params: [ - { name: 'label', label: 'New Display Name', type: 'text', required: true }, - // [#8554] The clone dialog is the exact moment an admin types a NEW - // name, so the scope has to be right here — a bare "Unique" tells the - // author the name is taken installation-wide when it is not. - { name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' }, - // `description` is prose, not a permission facet: it stays editable - // (renaming a clone's description is legitimate), while the five JSON - // facets below are declared `carryOver` — commit 0e4e51b0a's - // non-editable carry-over. Copied verbatim, shown read-only, never - // offered as a prefilled JSON textarea an admin could hand-mangle into - // a clone that grants MORE than its base. - { field: 'description', defaultFromRow: true }, - { field: 'object_permissions', defaultFromRow: true, carryOver: true }, - { field: 'field_permissions', defaultFromRow: true, carryOver: true }, - // [commit 5cb62d88b] The three facets the clone silently dropped. Same - // JSON-string shape as the two above: `permissionSetRowFields()` - // writes all five with `JSON.stringify`, and the data door parses all - // five back — the accept surface did not move, only what is SENT. - { field: 'system_permissions', defaultFromRow: true, carryOver: true }, - { field: 'row_level_security', defaultFromRow: true, carryOver: true }, - { field: 'tab_permissions', defaultFromRow: true, carryOver: true }, - // ⛔ `admin_scope` is deliberately absent — see `description` above. - ], - }, - ], - - listViews: { - active: { - type: 'grid', - name: 'active', - label: 'Active', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'description', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - inactive: { - type: 'grid', - name: 'inactive', - label: 'Inactive', - data: { provider: 'object', object: 'sys_permission_set' }, - columns: ['label', 'name', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: false }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_permsets: { - type: 'grid', - name: 'all_permsets', - label: 'All', - data: { provider: 'object', object: 'sys_permission_set' }, - // [ADR-0094] Surface provenance + the customized flag so admins can tell - // a packaged set (and whether they've overlaid it) from an env set. - columns: ['label', 'name', 'managed_by', 'customized', 'active', 'updated_at'], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - // [#8554] "unique per organization", not bare "unique". The bare wording - // described the installation-wide index this card removed. - description: - 'Machine name for the permission set, unique per organization. This is the set’s ' + - 'metadata identity (ADR-0094) and cannot be changed after creation — the data door ' + - 'rejects a rename; clone the set to a new name instead.', - // [ADR-0094] The name is the metadata key the record projects from, so it - // is immutable once the record exists. `record.id` is server-assigned: - // absent on the create form (editable), present on edit (locked). The - // data-door write-through independently rejects a rename (400), so this - // is the matching UI affordance rather than the only guard. - readonlyWhen: P`record.id != null && record.id != ''`, - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Permissions ────────────────────────────────────────────── - object_permissions: Field.textarea({ - label: 'Object Permissions', - required: false, - description: 'JSON-serialized object-level CRUD permissions', - group: 'Permissions', - }), - - field_permissions: Field.textarea({ - label: 'Field Permissions', - required: false, - description: 'JSON-serialized field-level read/write permissions', - group: 'Permissions', - }), - - system_permissions: Field.textarea({ - label: 'System Permissions', - required: false, - description: 'JSON-serialized array of system capability names (e.g. ["setup.access","studio.access","manage_users"])', - group: 'Permissions', - }), - - row_level_security: Field.textarea({ - label: 'Row-Level Security', - required: false, - description: 'JSON-serialized array of row-level security policies (USING/CHECK clauses)', - group: 'Permissions', - }), - - tab_permissions: Field.textarea({ - label: 'Tab Permissions', - required: false, - description: 'JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)', - group: 'Permissions', - }), - - admin_scope: Field.textarea({ - label: 'Delegated Admin Scope', - required: false, - description: - '[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, ' + - 'manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the ' + - 'user a SCOPED administrator within the declared business-unit subtree; enforced by the ' + - 'delegated-admin write gate.', - group: 'Permissions', - }), - - // ── Status ─────────────────────────────────────────────────── - active: Field.boolean({ - label: 'Active', - defaultValue: true, - group: 'Status', - }), - - // ── Provenance (ADR-0086 D3) ───────────────────────────────── - package_id: Field.text({ - label: 'Owning Package', - required: false, - readonly: true, - maxLength: 255, - description: - 'Package that ships this permission set (absent = environment-authored). ' + - 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', - group: 'Provenance', - }), - - // [A4 #2920] Unified provenance tri-state — platform / package / admin — - // shared verbatim with sys_capability and sys_position so an admin reads one - // vocabulary across all three RBAC catalogs. Converted from free `text` to a - // constrained `select` so the value is a labelled, i18n-able badge. - // Back-compat: legacy env rows carry 'user' (== admin). No runtime path - // branches on 'user' (every read keys on 'package' / 'platform', both - // unchanged), so those rows stay semantically correct; the boot normalizer - // (`normalizeManagedByVocab`) rewrites them to 'admin' idempotently. - managed_by: Field.select({ - label: 'Managed By', - required: false, - readonly: true, - defaultValue: 'admin', - description: - "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + - "permission sets and positions all share: 'platform' = shipped by the " + - "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + - "for admins); 'admin' = created/owned in this environment by an administrator " + - "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'Provenance', - }), - - // [ADR-0094] TRUE when this package-owned set is currently shadowed by an - // environment overlay (customized in this env, away from its shipped - // baseline). Projector-maintained; deleting the overlay (reset) clears it. - // Only meaningful on `managed_by:'package'` rows. - customized: Field.boolean({ - label: 'Customized', - defaultValue: false, - readonly: true, - description: - 'This packaged permission set has an environment customization overlay (ADR-0094). ' + - 'Reset it (delete through the data door) to return to the shipped baseline.', - group: 'Provenance', - }), - - // ── System ─────────────────────────────────────────────────── - id: Field.text({ - label: 'Permission Set ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [#8554] Scope spelled EXPLICITLY (ADR-0120 D1). On a DECLARED index bare - // `unique: true` is the positional spelling of `'global'` — the listed - // columns verbatim — so this was an installation-wide key on a tenant-scoped - // object. Measured live before the fix, two organizations and the same name: - // org_jia 201 / org_yi 409 UNIQUE_VIOLATION / org_yi unused name 201 / - // org_yi's own GET on the colliding name 0 rows. A per-value refusal on a - // row the caller cannot read is the #8323 cross-tenant existence oracle, and - // two tenants could not both name a set `sales_readonly`. - { fields: ['name'], unique: 'organization' }, - { fields: ['active'] }, - // ADR-0086 D3 — uninstall/upgrade query: "this package's own sets". - { fields: ['package_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts deleted file mode 100644 index 67270085094..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { F } from '@objectstack/spec'; - -/** - * sys_position_permission_set — Position ↔ PermissionSet binding. - * - * Allows administrators to compose a `sys_position` from one or more - * `sys_permission_set` rows. At request time, the runtime resolver - * (`resolveExecutionContext`) collects every permission set bound to - * the user's positions via this table and injects their names into - * `ExecutionContext.permissions[]` for downstream RBAC evaluation. - * - * Writes are guarded twice (both gates unconditional, ADR-0090): - * the audience-anchor gate (D5/D9 — no high-privilege set on - * `everyone`/`guest`) and the delegated-admin gate (D12 — non-tenant-admin - * writers need an adminScope whose allowlist covers the bound set). - * - * @namespace sys - */ -export const SysPositionPermissionSet = ObjectSchema.create({ - name: 'sys_position_permission_set', - label: 'Position Permission Set', - pluralLabel: 'Position Permission Sets', - icon: 'shield-plus', - isSystem: true, - // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: - // `suggested-audience-bindings.ts` inserts a binding with `context: callerCtx` - // (deliberately not isSystem). The bucket default is full CRUD, so no - // `userActions` block is needed — the DelegatedAdminGate is the authz. - managedBy: 'system-data', - description: 'Binds a permission set to a position.', - // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the - // registry's designate-only pass stamped `nameField: 'id'` (the first - // title-eligible field), so a renderer honouring ADR-0079's order (an - // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-security-assignment-display-title.test.ts` holds the two to the same text. - displayNameField: 'display_title', - nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{position_id} → {permission_set_id}', - highlightFields: ['position_id', 'permission_set_id'], - - fields: { - id: Field.text({ - label: 'Binding ID', - required: true, - readonly: true, - description: 'UUID of the position-permission-set binding.', - }), - - // [ADR-0079] The record title (`nameField` above). A formula is computed on - // read and has no stored column. It reads only this row's own columns — - // the two foreign keys, never a field of the looked-up records — and - // neither is hidden, permission-guarded or masked on this object, so the - // title carries nothing the declared read path withholds. Both are - // required, so the expression needs no null guard. - display_title: Field.formula({ - label: 'Title', - returnType: 'text', - expression: F`record.position_id + ' → ' + record.permission_set_id`, - description: 'Record title: the position and the permission set bound to it (computed on read)', - }), - - position_id: Field.lookup('sys_position', { - label: 'Position', - required: true, - description: 'Foreign key to sys_position.', - }), - - permission_set_id: Field.lookup('sys_permission_set', { - label: 'Permission Set', - required: true, - description: 'Foreign key to sys_permission_set.', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - }, - - indexes: [ - { fields: ['position_id', 'permission_set_id'], unique: 'global' }, - { fields: ['position_id'] }, - { fields: ['permission_set_id'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts deleted file mode 100644 index a81b163ba8a..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ /dev/null @@ -1,412 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js'; - -/** - * [#15972] The CEL predicate behind the `reserved_identity_name` rule below, - * assembled here so the object literal reads as one line and the two halves of - * the condition can each carry their own note. - * - * ⚠️ `&&` order is load-bearing. CEL absorbs an error on one side of `&&` when - * the other side is `false`, so putting the reserved-name test FIRST means the - * provenance test is only ever evaluated for a write that already spells a - * reserved name. Every other name short-circuits out before `managed_by` is - * read at all — which is what keeps the negative control (every other name - * still writes) independent of whatever `managed_by` happens to hold. - */ -const RESERVED_IDENTITY_NAME_CONDITION = - `record.name in ${reservedIdentityNamesCelList()}` - // The framework's OWN catalog rows are the exception, and the only one: - // `bootstrapBuiltinRoles` seeds exactly these names, per organization, - // stamped `managed_by: 'platform'` under an `isSystem` context. `system` is - // the legacy pre-A4 spelling of the same provenance — kept in lockstep with - // `SYSTEM_ROW_PROVENANCE` (security-plugin.ts), which maps BOTH to "the - // platform" — so a legacy row is not frozen by an invariant that lands after - // it. ⛔ `package` / `config` are deliberately NOT exempt: a stack that - // declares a position named `org_admin` is repurposing a built-in identity, - // which is the same defect arriving through the supply chain, and - // `bootstrapDeclaredPositions` stamps no provenance at all (the row defaults - // to `admin`), so it is refused here like any other tenant-grade write. - // - // A tenant cannot reach the exemption by CLAIMING it: `managed_by` is - // `readonly`, and the admin-door provenance gate refuses a payload that - // spells `platform`/`package` outright. - + ` && !(record.managed_by in ['platform', 'system'])`; - -/** - * sys_position — Position definitions (ADR-0090 D3). - * - * A position (岗位) is the flat capability-DISTRIBUTION group: users hold - * positions (`sys_user_position`), positions bind permission sets - * (`sys_position_permission_set`). Positions carry no capability of their - * own and no hierarchy — the visibility tree lives on `sys_business_unit`. - * - * @namespace sys - */ -export const SysPosition = ObjectSchema.create({ - name: 'sys_position', - label: 'Position', - pluralLabel: 'Positions', - icon: 'shield', - isSystem: true, - managedBy: 'config', - // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows - // (created via UI / API) but the schema itself is locked. - // ADR-0068 D3: position-DEFINITION authority follows the isolation boundary. - // Framework-reserved built-in identities (platform_admin / org_*) and the - // ADR-0090 D9 audience anchors (everyone / guest) are seeded with - // `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') and - // MUST NOT be repurposed by a tenant; ad-hoc position definitions in a shared - // cross-tenant kernel namespace are forbidden. - protection: { - lock: 'no-overlay', - reason: 'RBAC schema is platform-defined — see ADR-0010.', - docsUrl: 'https://objectstack.ai/docs/references/shared/protection', - }, - description: 'Position definitions for capability distribution (ADR-0090)', - displayNameField: 'label', - nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', - highlightFields: ['label', 'name', 'managed_by', 'active', 'is_default'], - - // Custom actions — positions drive capability distribution and are edited - // rarely but require the four high-frequency sysadmin affordances every IdP - // (Salesforce, ServiceNow, Okta) ships: activate/deactivate (lifecycle - // without losing assignments), mark default (auto-assign to new users), - // and clone (template for new positions). All operations hit the generic - // data CRUD endpoint exposed by `apiEnabled` — no custom server route - // required because `managedBy: 'config'` allows direct mutation. - actions: [ - { - name: 'activate_position', - label: 'Activate Position', - icon: 'circle-check', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { active: true }, - successMessage: 'Position activated', - refreshAfter: true, - }, - { - name: 'deactivate_position', - label: 'Deactivate Position', - icon: 'circle-off', - variant: 'danger', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { active: false }, - confirmText: 'Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.', - successMessage: 'Position deactivated', - refreshAfter: true, - }, - { - name: 'set_default_position', - label: 'Set as Default', - icon: 'star', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'PATCH', - target: '/api/v1/data/sys_position/{id}', - bodyExtra: { is_default: true }, - confirmText: 'Make this the default position for new users? Existing users are unaffected.', - successMessage: 'Default position updated', - refreshAfter: true, - }, - { - // Clone — POST a new sys_position row pre-filled from the source. The - // dialog asks only for the new API name / label so the operator - // can rename atomically; description is carried over via defaultFromRow. - // (Until #9885 this also copied the retired `permissions` column — a - // copy of a value nothing ever wrote; see the retirement note at the - // field group below. A clone does NOT copy permission-set bindings: - // `sys_position_permission_set` rows are runtime state, bound in Setup.) - name: 'clone_position', - label: 'Clone Position', - icon: 'copy', - variant: 'secondary', - mode: 'custom', - locations: ['list_item', 'record_header'], - type: 'api', - method: 'POST', - target: '/api/v1/data/sys_position', - bodyExtra: { is_default: false, active: true }, - successMessage: 'Position cloned', - refreshAfter: true, - params: [ - { name: 'label', label: 'New Display Name', type: 'text', required: true }, - // [#8468] The clone dialog is where an admin types a NEW position name, - // so it is the one place the scope has to be right at the moment of - // authoring: the name must be free within THIS organization, not across - // the installation. - { name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' }, - { field: 'description', defaultFromRow: true }, - ], - }, - ], - - listViews: { - active: { - type: 'grid', - name: 'active', - label: 'Active', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'is_default', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - default_positions: { - type: 'grid', - name: 'default_positions', - label: 'Default', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'description', 'active'], - filter: [{ field: 'is_default', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - custom: { - type: 'grid', - name: 'custom', - label: 'Custom', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'active', 'updated_at'], - filter: [{ field: 'is_default', operator: 'equals', value: false }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - all_positions: { - type: 'grid', - name: 'all_positions', - label: 'All', - data: { provider: 'object', object: 'sys_position' }, - columns: ['label', 'name', 'managed_by', 'active', 'is_default', 'updated_at'], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - // ── Identity ───────────────────────────────────────────────── - label: Field.text({ - label: 'Display Name', - required: true, - searchable: true, - maxLength: 255, - group: 'Identity', - }), - - name: Field.text({ - label: 'API Name', - required: true, - searchable: true, - maxLength: 100, - // [#8468] "unique per organization", not "unique". The bare wording - // asserted the installation-wide reading the declared index accidentally - // materialized, so the accident reached admins as contract. - description: - 'Machine name for the position, unique per organization ' + - '(e.g. sales_manager, hr_specialist)', - group: 'Identity', - }), - - description: Field.textarea({ - label: 'Description', - required: false, - group: 'Identity', - }), - - // ── Configuration ──────────────────────────────────────────── - // RETIRED (#9885, ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-20): `permissions` — a "JSON-serialized array of permission - // strings" textarea — was declared here while NO producer ever wrote it - // and NO runtime path ever read it (the object-scoped census read all - // sys_position-naming files; the bootstrap writers set label / - // description / managed_by / active / is_default only). Grants reach a - // position ONLY through permission-set bindings — - // `sys_position_permission_set` rows, resolved by position `name` in - // resolve-authz-context — so the declared column told an author (human - // or AI) that direct position-level permission strings are a platform - // capability; they are not. Re-declaring the column without a runtime - // reader in the same change is the exact defect the ruling removed. - // An undeclared field in a write is refused loudly by the engine's - // schema preflight (400 INVALID_FIELD); existing physical columns are - // untouched (ADR-0045 schema sync is additive). - - // ── Status ─────────────────────────────────────────────────── - active: Field.boolean({ - label: 'Active', - defaultValue: true, - group: 'Status', - }), - - is_default: Field.boolean({ - label: 'Default Position', - defaultValue: false, - description: 'Automatically assigned to new users', - group: 'Status', - }), - - // [ADR-0091 D3] Opt-in to self-service delegation of duty (职务代理). When - // true, a holder may assign this position to a delegate — time-boxed, - // reasoned, dual-audited — without being a delegated administrator. Admin- - // ish positions (those distributing an adminScope set) MUST NOT set this; - // the D7 lint and the D12 gate reject that combination. - delegatable: Field.boolean({ - label: 'Delegatable', - defaultValue: false, - description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.', - group: 'Status', - }), - - // ── System ─────────────────────────────────────────────────── - // [A4 #2920] Unified provenance tri-state — platform / package / admin — - // shared verbatim with sys_capability and sys_permission_set. Converted from - // free `text` to a constrained `select`. `platform` = a framework-reserved - // built-in identity position (seeded by bootstrapBuiltinRoles, read-only); - // `package` = stack/package-declared; `admin` = tenant-created in Setup. - // Back-compat: legacy rows may carry system (== platform) / config (== package) - // / user (== admin); the boot normalizer heals them to the canonical vocab, and - // the system-row write gate (SYSTEM_ROW_PROVENANCE, security-plugin.ts) guards - // both vocabularies — keep it in lockstep with any change here (#2926 ①). - // Built-in (`platform`) rows self-heal on the next bootstrap upsert. - managed_by: Field.select({ - label: 'Managed By', - readonly: true, - defaultValue: 'admin', - description: - 'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' + - 'permission sets and positions all share: platform = framework built-in ' + - '(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' + - 'may carry system (== platform) / config (== package) / user (== admin).', - options: [ - { value: 'platform', label: 'Platform' }, - { value: 'package', label: 'Package' }, - { value: 'admin', label: 'Admin' }, - ], - group: 'System', - }), - - id: Field.text({ - label: 'Position ID', - required: true, - readonly: true, - group: 'System', - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - }, - - indexes: [ - // [ADR-0120 D1, #8468] `'organization'`, NOT bare `true`. - // - // Positions are admin-authored (`managed_by: 'admin'` by default, created - // in Setup and by the `clone_position` action above), and the object takes - // no `tenancy` opt-out, so `organization_id` is injected. A position name - // is therefore one holder per ORGANIZATION, not one across the whole - // installation. - // - // Bare `true` on a DECLARED index is the positional spelling of `'global'` - // — the listed columns VERBATIM (see `normalizeDeclaredIndex`, which - // prepends the organization key part only for the explicit spelling). It is - // NOT the field-level `true`, which has meant per-organization since #3696; - // that divergence is "the #4986 trap" named in - // `packages/lint/src/data-model-rules.ts`, and this declaration was the - // third instance of it in the platform's own objects. - // - // Measured on a real engine before the fix (two organizations, same name): - // org_jia POST name=probe_pos_xtenant → 201 - // org_yi POST the SAME name → 409 UNIQUE_VIOLATION - // org_yi POST an unused name → 201 - // org_yi GET that name → total 0 - // A per-value refusal on a row the caller cannot read is a cross-tenant - // existence oracle, plus a dead end: the second organization could never - // name a position `sales_manager` if any other organization already had. - // - // The maintainer ruling of 2026-08-13 settles the family: an admin-authored - // name on a tenant-scoped object is scoped per organization. The hierarchy - // counter-argument does not apply here in any case — positions are - // deliberately FLAT (ADR-0090 D3, finalizing ADR-0057 D5); there is no - // `parent_id` on this object and no position tree to share a namespace. - // - // Platform-seeded rows (`bootstrapBuiltinRoles`, `managed_by: 'platform'`) - // carry no organization, and the organization key part is NULL-safe - // (`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so they remain - // unique among THEMSELVES and the bootstrap upsert-by-name is unaffected. - { fields: ['name'], unique: 'organization' }, - { fields: ['active'] }, - ], - - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child` - // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], - }, - - // ── [#15972] Reserved built-in identity names ──────────────────── - // - // The prose at the head of this file has said since ADR-0068 that the - // framework-reserved built-in identities "MUST NOT be repurposed by a - // tenant". ⚠️ That was a COMMENT, not a gate — the names stayed writable, - // and every defence against a tenant-minted `platform_admin` was a READER - // choosing to consult the capability rung rather than the name. This array - // is the enforcement of the sentence that was already here. - // - // Why the object's `validations`, and not a gate in the security plugin: - // the plugin's name gates (the curated-capability one, the provenance one) - // guard the ADMIN DOOR. A rule declared here is evaluated by the ENGINE - // (`objectql`'s rule validator) on insert, by-id update and multi-row - // update, so the data API, the seeders and metadata import are all covered - // by ONE refusal carrying ONE code (`VALIDATION_FAILED`) — which is what - // stops the two doors answering the same condition with two vocabularies. - // - // ⚠️ An INVARIANT, not a transition gate (see `ScriptValidationSchema`): the - // predicate is re-evaluated against the merged record on every write, so a - // row that ALREADY spells a reserved name is refused on any edit until it is - // renamed — frozen, not bricked, and deliberately so on a security invariant. - // Nothing here rewrites such a row; the read-only census - // (`scripts/measure-reserved-identity-name-census.mjs`) is what reports them. - validations: [ - { - // `cross_field`, not `script`: the condition genuinely reads two columns - // (the name AND its provenance), and only this variant carries `fields`, - // which is what attaches the violation to `name` instead of `_record` so - // a form can point at the offending input. - type: 'cross_field', - name: 'reserved_identity_name', - label: 'Reserved built-in identity name', - description: - 'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own ' - + 'catalog seed may spell one; a tenant- or package-authored position must choose another name.', - fields: ['name', 'managed_by'], - condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION }, - severity: 'error', - message: reservedIdentityNameMessage('name'), - }, - ], -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 82e475c5544..eed99fc71a8 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -76,13 +76,19 @@ export const SysUserPermissionSet = ObjectSchema.create({ // [ADR-0131 D10] The grant's former id reference to a catalog row. No // writer fills it and no reader confers anything through it: a grant names // its set by `permission_set` below. Optional, and kept only until D10 - // drops the column with its data (C7b); the one-time name backfill reads - // it on grants written before the name column existed. - permission_set_id: Field.lookup('sys_permission_set', { + // drops the column with its data (C7b), whose verified rewrite reads it on + // grants written before the name column existed. + // + // [C3 stage 8] Plain text, no longer a lookup: the catalog row object it + // pointed at is retired. Same name, same stored ids, same two indexes + // below, and the same `varchar(255)` a lookup is stored in, so the column + // itself does not change. + permission_set_id: Field.text({ label: 'Permission Set (legacy id)', required: false, + maxLength: 255, description: - '[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. ' + + '[ADR-0131 D10] Legacy id of a retired permission-set catalog row, dropped with its data by a later stage. ' + 'Not written and not read to grant anything: the grant names its set by permission_set.', }), diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 34a2f9eaccc..16ea4f35909 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -340,8 +340,8 @@ export class PackagedPermissionSetLockedError extends Error { + (operation === 'insert' ? `Choose a different name for your set, or clone '${name}' (the "Clone" action on the permission ` + `set) and edit the clone.` - : `Clone it instead (the "Clone" action on the permission set, or POST /api/v1/data/sys_permission_set ` - + `with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + : `Clone it instead (the "Clone" action on the permission set, or POST ` + + `/api/v1/security/permission-sets/${name}/clone with a new name) and edit the clone — the clone is your organization's own set, and upgrades keep ` + `flowing to '${name}' untouched.`), ); this.name = 'PackagedPermissionSetLockedError'; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index dc2b61f0096..0db7cffa699 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -41,8 +41,6 @@ import { unregisterGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; -import { createPositionWriteThrough } from './position-write-through.js'; -import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { reportCatalogReferences } from './catalog-reference-report.js'; import { explainAccessForAsker, @@ -58,11 +56,6 @@ import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/securi import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; -import { - createPermissionSetWriteThrough, - registerPermissionSetProjection, - reconcilePermissionSetProjection, -} from './permission-set-projection.js'; import { reportPackagedPermissionSetOverlays } from './packaged-permission-set-overlay-detection.js'; import { registerObjectPostureGate } from './object-posture-gate.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; @@ -158,7 +151,6 @@ import { readDeploymentOrgScopingEntitlement, type DeploymentOrgScopingEntitlementReading, } from '@objectstack/core'; -import { SysPositionDetailPage } from '@objectstack/platform-objects/pages'; import { securityObjects, securityDefaultPermissionSets, @@ -1530,26 +1522,24 @@ export class SecurityPlugin implements Plugin { ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, objects: securityObjects, - // [ADR-0090] SDUI detail page for sys_position — Holders (assignments, - // name-keyed junction) + Permission Sets (bindings) as pure - // record:related_list declarations; no bespoke UI. - pages: [SysPositionDetailPage], // Permission sets ride along on the manifest so the metadata service // can resolve them by name when SecurityPlugin middleware queries // `metadata.list('permissions')`. permissions: this.bootstrapPermissionSets, // ADR-0029 D7 — contribute the RBAC entries into the Setup app's - // `group_access_control` slot. This plugin owns these objects (K2), so it - // ships their menu too; when the plugin is absent the entries don't appear. + // `group_access_control` slot. This plugin owns the security catalog, so + // it ships its menu too; when the plugin is absent the entries don't + // appear. [ADR-0131 D3/D7] Each entry opens Setup's list of the registry + // type in its environment scope — the catalog has no object behind it. navigationContributions: [ { app: 'setup', group: 'group_access_control', priority: 100, items: [ - { id: 'nav_positions', type: 'object', label: 'Positions', objectName: 'sys_position', icon: 'shield-check' }, - { id: 'nav_capabilities', type: 'object', label: 'Capabilities', objectName: 'sys_capability', icon: 'badge-check' }, - { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, + { id: 'nav_positions', type: 'url', label: 'Positions', url: '/apps/setup/metadata/position?scope=environment', icon: 'shield-check' }, + { id: 'nav_capabilities', type: 'url', label: 'Capabilities', url: '/apps/setup/metadata/capability?scope=environment', icon: 'badge-check' }, + { id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment', icon: 'lock' }, ], }, ], @@ -4351,60 +4341,12 @@ export class SecurityPlugin implements Plugin { ); } - // [ADR-0094] Data-door write-through: every non-system CRUD write on - // `sys_permission_set` is redirected into the metadata store (the ONE - // authoritative store for definitions); the record is projector-owned. - // Registered AFTER the security middleware, so it runs INSIDE it — the - // two-doors gate, the delegated-admin gate, and the CRUD/FLS checks have - // all passed before a write is translated. Kernels without a capable - // metadata protocol pass through to the legacy direct write (single - // store — no split brain to prevent). - // - // [#20013] Wrapped so the security middleware can tell a write the door - // executed itself from one it passed on: the wrapper hands the door a - // `next` that records the pass-on, and a write that returns without it is - // recorded in `writesExecutedByDataDoor`. Observed, not declared — the - // door's own code is unchanged. - const permissionSetWriteThrough = createPermissionSetWriteThrough({ - ql, - metadata, - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - logger: ctx.logger, - }); - ql.registerMiddleware( - async (opCtx: any, next: () => Promise) => { - let passedOn = false; - await permissionSetWriteThrough(opCtx, async () => { - passedOn = true; - await next(); - }); - if (!passedOn && opCtx && typeof opCtx === 'object') writesExecutedByDataDoor.add(opCtx); - }, - { object: 'sys_permission_set' }, - ); + // [ADR-0131 D3, C3 stage 8] No data-door write-through is registered: the + // catalog objects are retired, so no `sys_permission_set` or `sys_position` + // write reaches the engine at all. A permission set or position is written + // through the metadata door alone, which registers it in the security + // catalog itself. - // [ADR-0131 D3, C2 stage S7] Under `single`, a non-system create, edit, - // rename or delete of a position also writes its DEFINITION through the - // metadata door at environment scope — the row first, then the - // definition; a refused definition undoes the row. Registered AFTER the - // security middleware, so it runs INSIDE it. A create or a rename into a - // name a package or built-in holds answers the metadata door's own - // refusal (C2 stage S10). Stands down for a walled posture, an edit that - // keeps or a delete of such a name, and a kernel without a capable - // metadata protocol. See `position-write-through.ts`. - ql.registerMiddleware( - createPositionWriteThrough({ - ql, - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - getPosture: () => this.tenancyPosture, - logger: ctx.logger, - }), - { object: 'sys_position' }, - ); // Defer platform admin bootstrap until all plugins finish starting — // sys_user / sys_user_permission_set objects must be registered (by @@ -4413,12 +4355,6 @@ export class SecurityPlugin implements Plugin { // kernel does not expose `hook` (test stubs). let bootstrapRanOnce = false; const readSeedSettlement = (): SeedSettlementSnapshot | undefined => readSeedSettlementSnapshot(ctx); - // [ADR-0094] Guard so the env-projection wiring runs exactly once even - // though runBootstrap re-runs (e.g. after the first user insert) — - // registerMutationProjector replaces idempotently, but the legacy - // onMetadataMutation fallback appends listeners, and re-wiring that would - // project each save N times. - let envProjectionWired = false; // [#22801 → A] Same once-guard for the `permission` mutation listener // that re-derives `everyone` (registered in `runBootstrap`). let everyoneMutationWired = false; @@ -4492,31 +4428,24 @@ export class SecurityPlugin implements Plugin { if (protocol && typeof protocol.registerUninstallCleanup === 'function') { protocol.registerUninstallCleanup( 'security.package-permissions', - async (args: { packageId: string }) => { - const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger); + async (args: { packageId: string; declared?: ReadonlyArray<{ type: string; name: string }> }) => { + // [ADR-0131 D3/D4] The package's sets are the names it declared, + // read off it by the uninstalling door before its withdrawal. + const setNames = (args.declared ?? []).filter((d) => d?.type === 'permission').map((d) => d.name); + const r = await cleanupPackagePermissions(ql, args.packageId, ctx.logger, setNames); // [#22801 → A] The package's acceptance rows are gone with it; // `everyone` is derived again without its sets. await this.declareEveryone(ctx.logger); return { success: true, - removed: r.sets + r.positionBindings + r.userGrants + r.suggestions, + removed: r.userGrants + r.suggestions, }; }, ); } - // [ADR-0094] Environment door — the `permission` mutation projector. - // The protocol AWAITS it inside saveMetaItem / publishMetaItem / - // deleteMetaItem, so the sys_permission_set record (and the metadata - // manager's in-memory entry, which the evaluator's registry-first - // list('permission') resolution reads) already reflects a Studio - // save when it returns — no projection race. Falls back to the - // fire-and-forget onMetadataMutation subscription (#2857/#2867) on - // protocols that predate registerMutationProjector. - if (!envProjectionWired) { - envProjectionWired = registerPermissionSetProjection(protocol, { - ql, metadata: this.metadata, logger: ctx.logger, - }); - } + // [ADR-0131 D3, C3 stage 8] No `permission` mutation projector: the + // metadata door's own registry write-through puts a saved set in the + // security catalog, and no row mirrors it. // [#3050] OWD posture authoring gate — pre-persistence veto on // runtime-authored `object` bodies: env-tighten-only over packaged // declarations (ADR-0086 D1) + external ≤ internal (ADR-0090 D11). @@ -4534,17 +4463,6 @@ export class SecurityPlugin implements Plugin { // non-packaged names keep the documented hatch behavior. One // spelling of "package-declared", two doors, one refusal. registerPackagedPermissionSetLockGate(protocol, ql); - // [ADR-0094 D4] Converge record ↔ metadata: project env overlays - // onto records (creating missing ones), backfill legacy data-door - // creations into the metadata store once, and heal drifted records - // from the effective body (metadata wins). Idempotent per boot. - try { - await reconcilePermissionSetProjection(protocol, { - ql, metadata: this.metadata, logger: ctx.logger, - }); - } catch (e) { - ctx.logger.warn('[security] permission-set projection reconciliation failed (ADR-0094)', { error: (e as Error).message }); - } // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce // 式)」, item 3] The DETECTION READING for sets that were already // silently forked before the save door was locked: count + NAMES, @@ -4617,36 +4535,6 @@ export class SecurityPlugin implements Plugin { void runGrantNameBackfill(); } - // [ADR-0131 D3, C2 stage S7] Give every row-only position — written in - // Setup before the write-through above existed — its environment - // definition, once per `single` deployment, recorded in `sys_migration`. - // At `kernel:bootstrapped` for the reason the grant-name backfill gives, - // and one more: an environment definition minted ahead of a package's - // declaration of the same name would refuse that package. See - // `position-environment-backfill.ts`. - const runPositionBackfill = async (): Promise => { - try { - let door: unknown = null; - try { door = (ctx as any).getService?.('protocol') ?? null; } catch { door = null; } - await runOneTimePositionEnvironmentBackfill(ql as any, { - posture: this.tenancyPosture, - catalog: createSecurityCatalogReader({ registry: (ql as any).registry, metadata: this.metadata }), - door, - logger: ctx.logger, - }); - } catch (e) { - ctx.logger.warn( - '[security] the row-only position backfill did not run — positions created in Setup before the ' + - 'environment write-through keep no environment definition until a later boot runs it', - { error: (e as Error)?.message }, - ); - } - }; - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('kernel:bootstrapped', runPositionBackfill); - } else { - void runPositionBackfill(); - } // [ADR-0131 D3/D4] The catalog reference report: per organization, every // assignment and grant whose name the security catalog does not resolve, diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index efbd77c2f66..d7314fdec3e 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const enObjects: NonNullable = { - sys_position: { - label: "Position", - pluralLabel: "Positions", - description: "Position definitions for capability distribution (ADR-0090)", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the position, unique per organization (e.g. sales_manager, hr_specialist)" - }, - description: { - label: "Description" - }, - active: { - label: "Active" - }, - is_default: { - label: "Default Position", - help: "Automatically assigned to new users" - }, - delegatable: { - label: "Delegatable", - help: "ADR-0091 D3: holders may self-service delegate this position, time-boxed." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: platform = framework built-in (read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows may carry system (== platform) / config (== package) / user (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - id: { - label: "Position ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - default_positions: { - label: "Default" - }, - custom: { - label: "Custom" - }, - all_positions: { - label: "All" - } - }, - _actions: { - activate_position: { - label: "Activate Position", - successMessage: "Position activated" - }, - deactivate_position: { - label: "Deactivate Position", - confirmText: "Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.", - successMessage: "Position deactivated" - }, - set_default_position: { - label: "Set as Default", - confirmText: "Make this the default position for new users? Existing users are unaffected.", - successMessage: "Default position updated" - }, - clone_position: { - label: "Clone Position", - successMessage: "Position cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "Capability", - pluralLabel: "Capabilities", - description: "Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access)." - }, - description: { - label: "Description" - }, - scope: { - label: "Scope", - help: "platform = a platform-wide power; org = scoped to an organization.", - options: { - platform: "Platform", - org: "Organization" - } - }, - managed_by: { - label: "Managed By", - help: "platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - package_id: { - label: "Owning Package", - help: "Package that ships this capability (absent = platform-curated or admin-created)." - }, - active: { - label: "Active", - help: "Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing." - }, - id: { - label: "Capability ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - platform: { - label: "Platform" - }, - org: { - label: "Organization" - }, - all_capabilities: { - label: "All" - } - }, - _actions: { - activate_capability: { - label: "Activate", - successMessage: "Capability activated" - }, - deactivate_capability: { - label: "Deactivate", - confirmText: "Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.", - successMessage: "Capability deactivated" - } - } - }, - sys_permission_set: { - label: "Permission Set", - pluralLabel: "Permission Sets", - description: "Named permission groupings for fine-grained access control", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the permission set, unique per organization. This is the set’s metadata identity (ADR-0094) and cannot be changed after creation — the data door rejects a rename; clone the set to a new name instead." - }, - description: { - label: "Description" - }, - object_permissions: { - label: "Object Permissions", - help: "JSON-serialized object-level CRUD permissions" - }, - field_permissions: { - label: "Field Permissions", - help: "JSON-serialized field-level read/write permissions" - }, - system_permissions: { - label: "System Permissions", - help: "JSON-serialized array of system capability names (e.g. [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "Row-Level Security", - help: "JSON-serialized array of row-level security policies (USING/CHECK clauses)" - }, - tab_permissions: { - label: "Tab Permissions", - help: "JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "Delegated Admin Scope", - help: "[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the user a SCOPED administrator within the declared business-unit subtree; enforced by the delegated-admin write gate." - }, - active: { - label: "Active" - }, - package_id: { - label: "Owning Package", - help: "Package that ships this permission set (absent = environment-authored). Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - customized: { - label: "Customized", - help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." - }, - id: { - label: "Permission Set ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - inactive: { - label: "Inactive" - }, - all_permsets: { - label: "All" - } - }, - _actions: { - activate_permission_set: { - label: "Activate", - successMessage: "Permission set activated" - }, - deactivate_permission_set: { - label: "Deactivate", - confirmText: "Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.", - successMessage: "Permission set deactivated" - }, - clone_permission_set: { - label: "Clone", - description: "Copies this set's permissions into a new organization-owned set you can edit. Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.", - successMessage: "Permission set cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - } - }, sys_user_permission_set: { label: "User Permission Set", pluralLabel: "User Permission Sets", @@ -307,7 +34,7 @@ export const enObjects: NonNullable = { }, permission_set_id: { label: "Permission Set (legacy id)", - help: "[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." + help: "[ADR-0131 D10] Legacy id of a retired permission-set catalog row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." }, permission_set: { label: "Permission Set", @@ -354,35 +81,6 @@ export const enObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "Position Permission Set", - pluralLabel: "Position Permission Sets", - description: "Binds a permission set to a position.", - fields: { - id: { - label: "Binding ID", - help: "UUID of the position-permission-set binding." - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "Position", - help: "Foreign key to sys_position." - }, - permission_set_id: { - label: "Permission Set", - help: "Foreign key to sys_permission_set." - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - } - }, sys_user_position: { label: "User Position", pluralLabel: "User Positions", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 9e7c04cca4e..1434a33c7e5 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const esESObjects: NonNullable = { - sys_position: { - label: "Puesto", - pluralLabel: "Puestos", - description: "Definiciones de puesto para la distribución de capacidades (ADR-0090)", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Nombre técnico del puesto, único por organización (p. ej. sales_manager, hr_specialist)." - }, - description: { - label: "Descripción" - }, - active: { - label: "Activo" - }, - is_default: { - label: "Puesto predeterminado", - help: "Se asigna automáticamente a los nuevos usuarios." - }, - delegatable: { - label: "Delegable", - help: "ADR-0091 D3: quienes la ostentan pueden delegar esta posición por autoservicio, con límite temporal." - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro, en el único vocabulario platform / package / admin que comparten capacidades, conjuntos de permisos y puestos: platform (integrado) / package (declarado) / admin (creado por el inquilino).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - id: { - label: "ID de puesto" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - active: { - label: "Activo" - }, - default_positions: { - label: "Predeterminado" - }, - custom: { - label: "Personalizado" - }, - all_positions: { - label: "Todos" - } - }, - _actions: { - activate_position: { - label: "Activar puesto", - successMessage: "Puesto activado" - }, - deactivate_position: { - label: "Desactivar puesto", - confirmText: "¿Desactivar este puesto? Los usuarios con el puesto conservan su asignación, pero el puesto deja de otorgar permisos hasta que se vuelva a activar.", - successMessage: "Puesto desactivado" - }, - set_default_position: { - label: "Establecer como predeterminado", - confirmText: "¿Convertir este en el puesto predeterminado para los nuevos usuarios? Los usuarios existentes no se ven afectados.", - successMessage: "Puesto predeterminado actualizado" - }, - clone_position: { - label: "Clonar puesto", - successMessage: "Puesto clonado", - params: { - label: { - label: "Nuevo nombre para mostrar" - }, - name: { - label: "Nuevo nombre de API", - helpText: "Nombre de máquina snake_case, único por organización" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "Capacidad", - pluralLabel: "Capacidades", - description: "Definiciones de capacidades de autorización referenciadas por nombre desde conjuntos de permisos y requisitos de recursos.", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Clave única de capacidad referenciada por systemPermissions / requiredPermissions (p. ej. manage_users, setup.access)." - }, - description: { - label: "Descripción" - }, - scope: { - label: "Ámbito", - help: "platform = potestad de toda la plataforma; org = limitada a una organización.", - options: { - platform: "Plataforma", - org: "Organización" - } - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro: platform / package (distribuido, no eliminable por el usuario) / admin (creado en Configuración).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - package_id: { - label: "Paquete propietario", - help: "Paquete que distribuye esta capacidad (ausente = curada por la plataforma o creada por un administrador)." - }, - active: { - label: "Activo", - help: "Indicador de catálogo/visibilidad para filtrar y revisar. NO tiene ningún efecto sobre la autorización: las concesiones de los conjuntos de permisos y los requiredPermissions de los recursos comparan los nombres de capacidad como cadenas y nunca leen esta fila, por lo que desmarcarlo no revoca nada." - }, - id: { - label: "ID de capacidad" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - platform: { - label: "Plataforma" - }, - org: { - label: "Organización" - }, - all_capabilities: { - label: "Todos" - } - }, - _actions: { - activate_capability: { - label: "Activar", - successMessage: "Capacidad activada" - }, - deactivate_capability: { - label: "Desactivar", - confirmText: "¿Desactivar esta capacidad? Es solo un indicador de catálogo: marca la fila como inactiva para filtrar y revisar en Setup. La autorización NO se ve afectada: los conjuntos de permisos que la conceden, y los recursos que la requieren, la comparan por nombre y se siguen resolviendo exactamente igual que antes.", - successMessage: "Capacidad desactivada" - } - } - }, - sys_permission_set: { - label: "Conjunto de permisos", - pluralLabel: "Conjuntos de permisos", - description: "Agrupaciones de permisos con nombre para un control de acceso detallado", - fields: { - label: { - label: "Nombre visible" - }, - name: { - label: "Nombre de API", - help: "Nombre técnico del conjunto de permisos, único por organización." - }, - description: { - label: "Descripción" - }, - object_permissions: { - label: "Permisos de objeto", - help: "Permisos CRUD a nivel de objeto serializados en JSON." - }, - field_permissions: { - label: "Permisos de campo", - help: "Permisos de lectura/escritura a nivel de campo serializados en JSON." - }, - system_permissions: { - label: "Permisos del sistema", - help: "Array serializado en JSON de nombres de capacidades del sistema (p. ej. [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "Seguridad a nivel de fila", - help: "Array serializado en JSON de políticas de seguridad a nivel de fila (cláusulas USING/CHECK)" - }, - tab_permissions: { - label: "Permisos de pestañas", - help: "Mapa serializado en JSON de la visibilidad de las pestañas de la app (visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "Alcance de administración delegada", - help: "[ADR-0090 D12] AdminScope serializado en JSON: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Poseer este conjunto convierte al usuario en administrador ACOTADO dentro del subárbol de unidad de negocio declarado; lo aplica la barrera de escritura de administración delegada." - }, - active: { - label: "Activo" - }, - package_id: { - label: "Paquete propietario", - help: "Paquete que distribuye este conjunto de permisos (ausente = creado en el entorno). Lo rellena bootstrapDeclaredPermissions; hace que la desinstalación/actualización del paquete quede bien definida." - }, - managed_by: { - label: "Gestionado por", - help: "Procedencia del registro, en el único vocabulario platform / package / admin que comparten capacidades, conjuntos de permisos y puestos: platform (distribuido) / package (empaquetado) / admin (creado en el entorno).", - options: { - platform: "Plataforma", - package: "Paquete", - admin: "Administrador" - } - }, - customized: { - label: "Personalizado", - help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." - }, - id: { - label: "ID de conjunto de permisos" - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - active: { - label: "Activo" - }, - inactive: { - label: "Inactivo" - }, - all_permsets: { - label: "Todos" - } - }, - _actions: { - activate_permission_set: { - label: "Activar", - successMessage: "Conjunto de permisos activado" - }, - deactivate_permission_set: { - label: "Desactivar", - confirmText: "¿Desactivar este conjunto de permisos? Las asignaciones existentes se mantienen, pero dejan de otorgar acceso hasta que se vuelva a activar.", - successMessage: "Conjunto de permisos desactivado" - }, - clone_permission_set: { - label: "Clonar", - description: "Copia los permisos de este conjunto en un nuevo conjunto propiedad de la organización que puedes editar. El alcance de administración delegada no se copia: concédelo deliberadamente en el nuevo conjunto si lo necesita.", - successMessage: "Conjunto de permisos clonado", - params: { - label: { - label: "Nuevo nombre para mostrar" - }, - name: { - label: "Nuevo nombre de API", - helpText: "Nombre de máquina snake_case, único por organización" - } - } - } - } - }, sys_user_permission_set: { label: "Conjunto de permisos de usuario", pluralLabel: "Conjuntos de permisos de usuario", @@ -354,35 +81,6 @@ export const esESObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "Conjunto de permisos de puesto", - pluralLabel: "Conjuntos de permisos de puesto", - description: "Vincula un conjunto de permisos a un puesto.", - fields: { - id: { - label: "ID de vinculación", - help: "UUID de la vinculación puesto-conjunto de permisos." - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "Puesto", - help: "Clave foránea a sys_position." - }, - permission_set_id: { - label: "Conjunto de permisos", - help: "Clave foránea a sys_permission_set." - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - } - }, sys_user_position: { label: "Puesto de usuario", pluralLabel: "Puestos de usuario", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index 608ee0175cd..6d5c033bc79 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const esESGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index d540be40ba6..fc0b17d2c6b 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const jaJPObjects: NonNullable = { - sys_position: { - label: "ポジション", - pluralLabel: "ポジション", - description: "ケイパビリティ配分のためのポジション定義(ADR-0090)", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "ポジションのマシン名(組織ごとに一意。例: sales_manager、hr_specialist)" - }, - description: { - label: "説明" - }, - active: { - label: "有効" - }, - is_default: { - label: "デフォルトポジション", - help: "新規ユーザーに自動的に割り当てられます" - }, - delegatable: { - label: "委任可能", - help: "ADR-0091 D3: 保持者はこのポジションを期限付きでセルフサービス委任できます。" - }, - managed_by: { - label: "管理元", - help: "レコードの出所(ケイパビリティ・権限セット・ポジションで共通の platform / package / admin の語彙):platform(組み込み)/ package(宣言済み)/ admin(テナント作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - id: { - label: "ポジション ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - active: { - label: "有効" - }, - default_positions: { - label: "デフォルト" - }, - custom: { - label: "カスタム" - }, - all_positions: { - label: "すべて" - } - }, - _actions: { - activate_position: { - label: "ポジションを有効化", - successMessage: "ポジションが有効化されました" - }, - deactivate_position: { - label: "ポジションを無効化", - confirmText: "このポジションを無効化しますか?このポジションを持つユーザーの割り当ては維持されますが、再度有効化するまで権限の付与は停止されます。", - successMessage: "ポジションが無効化されました" - }, - set_default_position: { - label: "デフォルトに設定", - confirmText: "このポジションを新規ユーザーのデフォルトポジションにしますか?既存のユーザーには影響しません。", - successMessage: "デフォルトポジションを更新しました" - }, - clone_position: { - label: "ポジションを複製", - successMessage: "ポジションを複製しました", - params: { - label: { - label: "新しい表示名" - }, - name: { - label: "新しい API 名", - helpText: "snake_case マシン名(組織ごとに一意)" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "ケイパビリティ", - pluralLabel: "ケイパビリティ", - description: "権限セットおよびリソース要件から名前で参照される認可ケイパビリティ定義。", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "systemPermissions / requiredPermissions から参照される一意のケーパビリティキー(例: manage_users、setup.access)。" - }, - description: { - label: "説明" - }, - scope: { - label: "スコープ", - help: "platform = プラットフォーム全体の権限、org = 組織単位に限定。", - options: { - platform: "プラットフォーム", - org: "組織" - } - }, - managed_by: { - label: "管理元", - help: "レコードの出所:platform / package(配布物、ユーザー削除不可)/ admin(設定で作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - package_id: { - label: "所有パッケージ", - help: "このケーパビリティを提供するパッケージ(未設定はプラットフォーム標準または管理者作成)。" - }, - active: { - label: "有効", - help: "絞り込みと確認のためのカタログ/表示用フラグです。認可には一切影響しません。権限セットの付与もリソースの requiredPermissions も、ケーパビリティ名を文字列として照合し、この行を読むことはないため、これを解除しても何も取り消されません。" - }, - id: { - label: "ケーパビリティ ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - platform: { - label: "プラットフォーム" - }, - org: { - label: "組織" - }, - all_capabilities: { - label: "すべて" - } - }, - _actions: { - activate_capability: { - label: "有効化", - successMessage: "ケーパビリティを有効化しました" - }, - deactivate_capability: { - label: "無効化", - confirmText: "このケーパビリティを無効化しますか?これはカタログ上のフラグにすぎず、Setup での絞り込みと確認に使われます。認可には影響しません。このケーパビリティを付与する権限セットも、これを要求するリソースも、名前で照合されるため、これまでどおり解決され続けます。", - successMessage: "ケーパビリティを無効化しました" - } - } - }, - sys_permission_set: { - label: "権限セット", - pluralLabel: "権限セット", - description: "細かいアクセス制御のための権限グループ", - fields: { - label: { - label: "表示名" - }, - name: { - label: "API 名", - help: "権限セットのマシン名(組織ごとに一意)" - }, - description: { - label: "説明" - }, - object_permissions: { - label: "オブジェクト権限", - help: "JSON シリアライズされたオブジェクトレベルの CRUD 権限" - }, - field_permissions: { - label: "フィールド権限", - help: "JSON シリアライズされたフィールドレベルの読み取り/書き込み権限" - }, - system_permissions: { - label: "システム権限", - help: "システムケーパビリティ名のJSONシリアライズ配列(例: [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "行レベルセキュリティ", - help: "行レベルセキュリティポリシーのJSONシリアライズ配列(USING/CHECK 句)" - }, - tab_permissions: { - label: "タブ権限", - help: "アプリのタブ表示のJSONシリアライズマップ(visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "委任管理スコープ", - help: "[ADR-0090 D12] JSON シリアライズされた AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。このセットを保持すると、宣言されたビジネスユニット配下に限定されたスコープ付き管理者になります。委任管理の書き込みゲートで強制されます。" - }, - active: { - label: "有効" - }, - package_id: { - label: "所有パッケージ", - help: "この権限セットを提供するパッケージ(未設定は環境で作成)。bootstrapDeclaredPermissions が設定し、パッケージのアンインストール/アップグレードの挙動を明確にします。" - }, - managed_by: { - label: "管理元", - help: "レコードの出所(ケイパビリティ・権限セット・ポジションで共通の platform / package / admin の語彙):platform(配布)/ package(パッケージ)/ admin(環境作成)。", - options: { - platform: "プラットフォーム", - package: "パッケージ", - admin: "管理者" - } - }, - customized: { - label: "カスタマイズ済み", - help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" - }, - id: { - label: "権限セット ID" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - active: { - label: "有効" - }, - inactive: { - label: "無効" - }, - all_permsets: { - label: "すべて" - } - }, - _actions: { - activate_permission_set: { - label: "有効化", - successMessage: "権限セットが有効化されました" - }, - deactivate_permission_set: { - label: "無効化", - confirmText: "この権限セットを無効化しますか?既存の割り当ては維持されますが、再度有効化するまでアクセスの付与は停止されます。", - successMessage: "権限セットが無効化されました" - }, - clone_permission_set: { - label: "複製", - description: "このセットの権限を、編集可能な組織所有の新しい権限セットにコピーします。委任管理スコープはコピーされません。新しいセットに必要な場合は、個別に付与してください。", - successMessage: "権限セットを複製しました", - params: { - label: { - label: "新しい表示名" - }, - name: { - label: "新しい API 名", - helpText: "snake_case マシン名(組織ごとに一意)" - } - } - } - } - }, sys_user_permission_set: { label: "ユーザー権限セット", pluralLabel: "ユーザー権限セット", @@ -354,35 +81,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "ポジション権限セット", - pluralLabel: "ポジション権限セット", - description: "権限セットをポジションにバインドします。", - fields: { - id: { - label: "バインド ID", - help: "ポジション権限セットバインドの UUID。" - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "ポジション", - help: "sys_position への外部キー。" - }, - permission_set_id: { - label: "権限セット", - help: "sys_permission_set への外部キー。" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - } - }, sys_user_position: { label: "ユーザーポジション", pluralLabel: "ユーザーポジション", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index 51efd73cf9b..37812fced2f 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 4859358e9c9..1488d2650b4 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const zhCNObjects: NonNullable = { - sys_position: { - label: "岗位", - pluralLabel: "岗位", - description: "用于分发授权能力的岗位定义(ADR-0090)", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "岗位的机器名称,在每个组织内唯一(例如 sales_manager、hr_specialist)" - }, - description: { - label: "描述" - }, - active: { - label: "启用" - }, - is_default: { - label: "默认岗位", - help: "自动分配给新用户" - }, - delegatable: { - label: "可委派", - help: "ADR-0091 D3:持有者可自助委派该岗位,且有时限。" - }, - managed_by: { - label: "管理来源", - help: "记录来源(能力、权限集与岗位共用同一套 platform / package / admin 取值):platform(平台内置)/ package(应用包声明)/ admin(租户创建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - id: { - label: "岗位 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - active: { - label: "启用" - }, - default_positions: { - label: "默认" - }, - custom: { - label: "自定义" - }, - all_positions: { - label: "全部" - } - }, - _actions: { - activate_position: { - label: "激活岗位", - successMessage: "岗位已激活" - }, - deactivate_position: { - label: "停用岗位", - confirmText: "确定要停用此岗位吗?拥有该岗位的用户仍保留其分配,但在重新激活之前该岗位将不再授予权限。", - successMessage: "岗位已停用" - }, - set_default_position: { - label: "设为默认", - confirmText: "将此岗位设为新用户的默认岗位吗?现有用户不受影响。", - successMessage: "已更新默认岗位" - }, - clone_position: { - label: "克隆岗位", - successMessage: "已克隆岗位", - params: { - label: { - label: "新显示名称" - }, - name: { - label: "新 API 名称", - helpText: "snake_case 机器名称,在每个组织内唯一" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "能力", - pluralLabel: "能力", - description: "授权能力定义,由权限集和资源需求按名称引用。", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "唯一的能力键,由 systemPermissions / requiredPermissions 按名称引用(如 manage_users、setup.access)。" - }, - description: { - label: "描述" - }, - scope: { - label: "范围", - help: "platform = 平台级权能;org = 限定于某个组织。", - options: { - platform: "平台", - org: "组织" - } - }, - managed_by: { - label: "管理来源", - help: "记录来源:platform / package(随包发布,不可用户删除)/ admin(在设置中创建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - package_id: { - label: "所属应用包", - help: "发布该能力的应用包(为空表示平台内置或管理员创建)。" - }, - active: { - label: "启用", - help: "目录/可见性标记,用于筛选和查看。它对授权没有任何影响:权限集的授予与资源的 requiredPermissions 都按字符串匹配能力名称,从不读取该行,因此清除它不会撤销任何权限。" - }, - id: { - label: "能力 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - platform: { - label: "平台" - }, - org: { - label: "组织" - }, - all_capabilities: { - label: "全部" - } - }, - _actions: { - activate_capability: { - label: "激活", - successMessage: "已启用能力" - }, - deactivate_capability: { - label: "停用", - confirmText: "停用该能力?这只是目录标记,用于在 Setup 中筛选和查看。授权不受影响——授予该能力的权限集,以及要求该能力的资源,都按名称匹配,仍会照常解析。", - successMessage: "已停用能力" - } - } - }, - sys_permission_set: { - label: "权限集", - pluralLabel: "权限集", - description: "用于精细化访问控制的命名权限分组", - fields: { - label: { - label: "显示名称" - }, - name: { - label: "API 名称", - help: "权限集的机器名称,在每个组织内唯一" - }, - description: { - label: "描述" - }, - object_permissions: { - label: "对象权限", - help: "对象级 CRUD 权限的 JSON 序列化内容" - }, - field_permissions: { - label: "字段权限", - help: "字段级读写权限的 JSON 序列化内容" - }, - system_permissions: { - label: "系统权限", - help: "系统能力名称的 JSON 序列化数组(例如 [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "行级安全", - help: "行级安全策略的 JSON 序列化数组(USING/CHECK 子句)" - }, - tab_permissions: { - label: "标签页权限", - help: "应用标签页可见性的 JSON 序列化映射(visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "委派管理范围", - help: "[ADR-0090 D12] JSON 序列化的 AdminScope:{ businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。持有该权限集会使用户成为所声明业务单元子树内的「受限范围」管理员,由委派管理写入门禁强制执行。" - }, - active: { - label: "启用" - }, - package_id: { - label: "所属应用包", - help: "发布该权限集的应用包(为空表示环境内编写)。由 bootstrapDeclaredPermissions 填充,使应用包的卸载/升级行为有明确定义。" - }, - managed_by: { - label: "管理来源", - help: "记录来源(能力、权限集与岗位共用同一套 platform / package / admin 取值):platform(平台发布)/ package(应用包发布)/ admin(环境自建)。", - options: { - platform: "平台", - package: "应用包", - admin: "管理员" - } - }, - customized: { - label: "已定制", - help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" - }, - id: { - label: "权限集 ID" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - active: { - label: "启用" - }, - inactive: { - label: "停用" - }, - all_permsets: { - label: "全部" - } - }, - _actions: { - activate_permission_set: { - label: "激活", - successMessage: "权限集已激活" - }, - deactivate_permission_set: { - label: "停用", - confirmText: "确定要停用此权限集吗?现有分配仍将保留,但在重新激活之前将不再授予访问权限。", - successMessage: "权限集已停用" - }, - clone_permission_set: { - label: "克隆", - description: "将此权限集的权限复制到一个新的、由组织拥有且可编辑的权限集。委派管理范围不会被复制——如果新权限集需要,请单独在其上授予。", - successMessage: "已克隆权限集", - params: { - label: { - label: "新显示名称" - }, - name: { - label: "新 API 名称", - helpText: "snake_case 机器名称,在每个组织内唯一" - } - } - } - } - }, sys_user_permission_set: { label: "用户权限集", pluralLabel: "用户权限集", @@ -354,35 +81,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "岗位权限集", - pluralLabel: "岗位权限集", - description: "将权限集绑定到岗位。", - fields: { - id: { - label: "绑定 ID", - help: "岗位-权限集绑定记录的 UUID。" - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "岗位", - help: "指向 sys_position 的外键。" - }, - permission_set_id: { - label: "权限集", - help: "指向 sys_permission_set 的外键。" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - } - }, sys_user_position: { label: "用户岗位", pluralLabel: "用户岗位", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index f97c9467f5f..6444f5e4e3a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -18,9 +18,6 @@ */ export const zhCNGeneratedSourceHashes: Readonly> = { - "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", - "objects.sys_position_permission_set.fields.display_title.help": "b99cbdc42ee68f06", - "objects.sys_position_permission_set.fields.display_title.label": "70f7aadecce647a5", "objects.sys_user_permission_set._validations.validity_window_order.message": "b2216aacc3b6f97b", "objects.sys_user_permission_set.fields.display_title.help": "ee7ef063488cd6c3", "objects.sys_user_permission_set.fields.display_title.label": "70f7aadecce647a5", diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 7d173f74220..c8ca7f2e048 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -439,10 +439,7 @@ export class SharingService implements ISharingService { 'sys_user', 'sys_organization', 'sys_member', - 'sys_position', - 'sys_permission_set', 'sys_user_permission_set', - 'sys_position_permission_set', ...(options.bypassObjects ?? []), ]); } diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index f45a7e15ce6..a6a6aa6da57 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -120,7 +120,7 @@ const manifest = { label: 'Self-registration permission set', required: false, description: - 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + 'Name of the permission set granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', visible: "${data.audience_posture === 'email_domain' || data.audience_posture === 'open'}", }, diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index f6c8feddcba..e64b3b5130e 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -179,7 +179,7 @@ export const en: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'Self-registration permission set', - help: 'sys_permission_set name granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', + help: 'Name of the permission set granted to each self-registrant (declaring member_default explicitly is fine; admin_full_access is refused). Required whenever the posture permits self-registration.', }, password_min_length: { label: 'Minimum password length' }, password_max_length: { label: 'Maximum password length', help: 'Guards against denial-of-service via very long password hashing.' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 541893918b8..bbf4861e571 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -146,7 +146,7 @@ export const esES: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'Conjunto de permisos de autorregistro', - help: 'Nombre del sys_permission_set que recibe cada usuario autorregistrado (declarar member_default explícitamente es válido; admin_full_access se rechaza).', + help: 'Nombre del conjunto de permisos que recibe cada usuario autorregistrado (declarar member_default explícitamente es válido; admin_full_access se rechaza).', }, password_min_length: { label: 'Longitud mínima de contraseña' }, password_max_length: { label: 'Longitud máxima de contraseña', help: 'Un límite superior protege frente a la denegación de servicio por el hasheo de contraseñas muy largas.' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index a3fc0989896..cfe30048bea 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -146,7 +146,7 @@ export const jaJP: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: 'セルフ登録の権限セット', - help: '各セルフ登録ユーザーに付与される sys_permission_set 名(member_default の明示宣言は可。admin_full_access は拒否されます)。', + help: '各セルフ登録ユーザーに付与される権限セット名(member_default の明示宣言は可。admin_full_access は拒否されます)。', }, password_min_length: { label: 'パスワードの最小文字数' }, password_max_length: { label: 'パスワードの最大文字数', help: '非常に長いパスワードのハッシュ化によるサービス拒否を防ぎます。' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 0ffa332cfde..808117d28f9 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -288,7 +288,7 @@ export const zhCN: PlatformTranslationData = { }, audience_self_registration_permission_set: { label: '自助注册权限集', - help: '每个自助注册用户获得的 sys_permission_set 名称(可显式声明 member_default;admin_full_access 会被拒绝)。', + help: '每个自助注册用户获得的权限集名称(可显式声明 member_default;admin_full_access 会被拒绝)。', }, password_min_length: { label: '密码最小长度' }, password_max_length: { label: '密码最大长度', help: '防止超长密码哈希导致的拒绝服务。' }, diff --git a/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts b/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts new file mode 100644 index 00000000000..bce42c314ac --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (C3 stage 8) — the four security-catalog row objects are retired. +// Registered because a client of their data and REST routes, a permission set +// granting object permissions on them, or a seed row targeting one must move to +// the security catalog instead. +export const entry: SemanticMigration = { + id: 'catalog-row-objects-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_position, sys_permission_set, sys_position_permission_set and sys_capability objects — their ' + + 'data and REST routes, their Setup object pages, object permissions naming them, and seed rows targeting them', + replacement: + 'read and write the security catalog through the metadata door: `GET|PUT|DELETE /api/v1/meta/permission/:name` ' + + 'and `/api/v1/meta/position/:name` (a position names its sets in `permissionSets`), read-only ' + + '`GET /api/v1/meta/capability`; copy a set with `POST /api/v1/security/permission-sets/:name/clone`. ' + + 'Declare a capability with `defineCapability` (`stack.capabilities`) instead of a `sys_capability` seed row, ' + + 'and drop object permissions on the four names — they grant nothing', + reason: + 'ADR-0131 D3: positions, permission sets and capabilities have one home, the environment registry, fed by ' + + 'package declarations and by environment metadata saved through the metadata door. The row objects that ' + + 'mirrored them were read by nothing that authorizes, so they are no longer registered; their physical ' + + 'tables and rows stay until the organization-ownership migration (ADR-0131 D10) takes them. ADR-0087.', + acceptanceCriteria: + 'No client calls `/api/v1/data/` on one of the four names, no permission set grants object permissions on ' + + 'them, no seed dataset targets them, and every position, permission set and capability the deployment ' + + 'relies on is served by `GET /api/v1/meta//:name`.', +}; diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index f683384c52f..4fd006c5280 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -36,7 +36,7 @@ export const permissionForm = defineForm({ // them was the Source tab's free-text JSON. `description` is persisted on // sys_permission_set and shown in Setup; the other three are the ADR-0086 // D3 provenance pair plus the ADR-0090 D5 baseline flag. - { field: 'description', type: 'textarea', colSpan: 2, helpText: 'Human-readable description shown in Setup (persisted as sys_permission_set.description).' }, + { field: 'description', type: 'textarea', colSpan: 2, helpText: 'Human-readable description shown in Setup.' }, { field: 'isDefault', type: 'boolean', colSpan: 1, helpText: 'App baseline for the everyone position (ADR-0090 D5): an app-level set is auto-bound at boot; a package-level set becomes an install-time suggestion an admin confirms. Default false.' }, { field: 'managedBy', type: 'select', colSpan: 1, helpText: 'Record provenance (ADR-0086 D3): who owns this set across upgrades.', options: [ { label: 'Package (upgrade-owned metadata)', value: 'package' }, diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index a09d39f0898..505380ede05 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -827,7 +827,7 @@ export const PermissionSetSchema = lazySchema(() => strictObject( * stripped at parse (the ADR-0078 §3 inverse-drift class); the `.strict()` * gate surfaced the gap and it is now declared. */ - description: z.string().optional().describe('Human-readable description shown in Setup (persisted as sys_permission_set.description)'), + description: z.string().optional().describe('Human-readable description shown in Setup'), /** * [ADR-0086 D3] Owning package for a package-shipped permission set diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 8f0d17fed93..a92454d61b5 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -447,7 +447,7 @@ export const AudienceConfigSchema = lazySchema(() => z.object({ * receive the platform-admin set). */ selfRegistrationPermissionSet: z.string().min(1).optional().describe( - 'sys_permission_set name granted to each self-registrant. Required when posture is email_domain or open; ' + + 'Name of the permission set granted to each self-registrant. Required when posture is email_domain or open; ' + 'refused for invite_only. admin_full_access is refused.', ), }).superRefine((value, ctx) => { diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 1a0c44d5213..fb73d14ddcb 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -105,10 +105,6 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly drifted[key]).join('/')) && provDrifted.includes(FLOORS.map(([key]) => MEASURED[key]).join('/')) && provDrifted.includes(FLOORS.map(([, min]) => min).join('/')) && provDrifted.includes(MEASURED.ref), provDrifted); diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index bfea2ed6d67..3c980b5c546 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 84, - "inReach": 49, + "registered": 80, + "inReach": 45, "outOfReach": 35 }, "reasonTotals": { @@ -140,13 +140,6 @@ "tenantField": "organization_id", "reasons": [] }, - { - "name": "sys_capability", - "file": "packages/plugins/plugin-security/src/objects/sys-capability.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_comment", "file": "packages/plugins/plugin-audit/src/objects/sys-comment.object.ts", @@ -451,13 +444,6 @@ "managedBy: 'better-auth'" ] }, - { - "name": "sys_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_platform_setting", "file": "packages/platform-objects/src/system/sys-platform-setting.object.ts", @@ -467,20 +453,6 @@ "systemFields.tenant: false" ] }, - { - "name": "sys_position", - "file": "packages/plugins/plugin-security/src/objects/sys-position.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, - { - "name": "sys_position_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_presence", "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts", diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 74f027b4d31..04e0104f698 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -1531,6 +1531,18 @@ export const UNTYPED_RECEIVERS = [ engine: true, what: '`ql: any` seed helper writing `schema.name` -- a runtime object name off the registered schema', }, + { + file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', + receiver: 'ql', + engine: true, + what: '`ql: any` writing `sys_permission_set` -- a catalog row object retired by C3 stage 8 (ADR-0131 D3), so the name is no longer a declared object; the module is unwired and deleted with stage 7a', + }, + { + file: 'packages/plugins/plugin-security/src/position-write-through.ts', + receiver: 'ql', + engine: true, + what: '`ql: any` writing `POSITION_OBJECT` (`sys_position`) -- a catalog row object retired by C3 stage 8 (ADR-0131 D3); the module is unwired and deleted with stage 7b', + }, { file: 'packages/services/service-settings/src/settings-service-plugin.ts', receiver: 'eng', From 9d55c0589a197294abfd535183bb49db4a7bd944 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:45:56 +0000 Subject: [PATCH 19/39] test(plugin-security): the uninstall-cleanup suite hands the sweep D's declared names shape D (#22900) landed the uninstall cleanup's declared names as `UninstallDeclared` ({ permission: string[] }), not the bare name array the stage-8 branch was built against. The suite now passes that shape and drops its signature cast; D's two named-grant pins are restated over S8-T's fixture so each holds whether the sweep also reaches the package's catalog rows (main) or reaches its sets by the declared names alone (after C3 stage 8). The runner suite's comment attributes `declared` to the names D captures. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- ...protocol.uninstall-cleanups-runner.test.ts | 4 +- .../src/cleanup-package-permissions.test.ts | 40 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index 409da59850b..c4e0707d7dc 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,8 +99,8 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - // Its own request — plus, since C3 stage 8, the catalog items the package - // declared (`declared`), read before its withdrawal. + // Its own request — plus, when the package declared permission sets, their + // names (`declared`, ADR-0131 D4), read before its withdrawal. expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index 75db312aa7d..3895d8f8053 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -3,23 +3,15 @@ // (ADR-0086 D3 provenance consumed at last; ADR-0090 D5 "no ghost grants"). import { describe, it, expect } from 'vitest'; -import { cleanupPackagePermissions as cleanup } from './cleanup-package-permissions'; +import { cleanupPackagePermissions } from './cleanup-package-permissions'; /** - * The sweep, with the declared-names argument C3 stage 8 adds. Spelled through - * this signature so the suite compiles against the module before and after - * that stage: before it, the sweep finds the package's sets by its catalog - * rows and ignores the names; after it, it finds them by the names. + * The permission-set names the uninstalling door read off the package before + * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Handed to the sweep, they + * revoke the same grants whether it also finds the package's sets by their + * catalog rows (before C3 stage 8) or by these names alone (after it). */ -const cleanupPackagePermissions = cleanup as ( - ql: any, - packageId: string, - logger?: Parameters[2], - declaredSetNames?: readonly string[], -) => ReturnType; - -/** The permission-set names the uninstalled package declared (read off it before its withdrawal). */ -const DECLARED = ['crm_readonly', 'crm_admin']; +const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; /** * A store holding both shapes a grant has carried: every grant names its set @@ -188,17 +180,25 @@ describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declare it('revokes every user grant naming a declared set, and only those', async () => { const ql = withNamedGrants(); - const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { permission: ['crm_catalog_user'] }); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { + permission: [...DECLARED.permission, 'crm_catalog_user'], + }); - expect(out.userGrants).toBe(3); // the legacy row's grant (ups_1) and the two naming the declared set - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_other']); + // The two grants naming the package's row-backed sets and the two naming + // its registry-only set. + expect(out.userGrants).toBe(4); + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other', 'ups_named_other']); }); - it('control — handed no names, a grant naming a catalog set survives: the cleanup is as it was', async () => { + it('control — handed no names, a grant naming a registry-only set survives', async () => { const ql = withNamedGrants(); const out = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_1', 'ups_named_2', 'ups_named_other']); + expect(out).toMatchObject({ suggestions: 2, refused: 0 }); + // Whatever the row legs reach while the catalog rows still exist, no grant + // naming a set the sweep was not handed is revoked. + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual( + expect.arrayContaining(['ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other']), + ); }); }); From 008ca32a75b74ebb16f26c420c6eeac6d54ab609 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:12:49 +0000 Subject: [PATCH 20/39] test: suites main brought in since S8-T was built stop depending on the four catalog row objects Three files that reached main after S8-T's base read the retired objects: - walled-catalog-data-door.test.ts is deleted: it pins the walled catalog refusal's data-door registration on sys_position, sys_permission_set and sys_capability, which stage 8 unregisters with the write-throughs (the precedent of position-write-through.test.ts); - wall-catalog-refusal's data-door case answers 403 PERMISSION_DENIED while the row objects are registered and 404 OBJECT_NOT_FOUND once they are retired, and asserts no definition either way; - mail-message-pool-read-gate reads its fixture sets from the security catalog: no boot has written a sys_permission_set row since D, so the row read failed on main as well. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/walled-catalog-data-door.test.ts | 227 ------------------ ...ail-message-pool-read-gate.dogfood.test.ts | 7 +- .../test/wall-catalog-refusal.dogfood.test.ts | 25 +- 3 files changed, 24 insertions(+), 235 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts deleted file mode 100644 index 003a885f3f6..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the - * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). - * - * Measured before it on this same harness shape (a real `ObjectQL` over a real - * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the - * organization-scoping service): an organization-bound administrator holding - * the superuser wildcard created a position at the data door, and it landed - * as a row of that organization. Nothing judged a catalog create by posture. - * - * Pinned here, per posture: - * - `isolated` and `group`: the tenant's create, update and delete of a - * position, a permission set and a capability are refused with the ADR-0112 - * envelope `403 PERMISSION_DENIED`, and nothing is stored; - * - the CONTROL: the same tenant still assigns a catalog position to a member - * of its organization; - * - the platform operator rung and a system write are admitted; - * - `single`: the same tenant's create is accepted, unchanged. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { resolveThrownHttpError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; - -import { SecurityPlugin } from './security-plugin.js'; -import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysCapability } from './objects/sys-capability.object.js'; -import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; - -const ORG = 'org_a'; - -/** The fallback every authenticated caller resolves: it grants nothing here. */ -const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; - -/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -type Posture = 'single' | 'isolated' | 'group'; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(posture: Posture) { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.walled-catalog-data-door', - name: 'Walled catalog data door', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysCapability, SysMember, SysMetadataActivation, - ], - } as any); - await engine.syncSchemas(); - for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { - if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); - } - engines.push(engine); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata: { - get: async (type: string, name: string) => - type === 'permission' - ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async () => [MEMBER_DEFAULT, QA_ADMIN], - }, - ...(posture === 'single' - ? {} - : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), - }; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - hook: vi.fn(), - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - const walled = posture !== 'single'; - const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; - // The catalog the tenant may assign: one position, declared in the registry. - engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, - 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); - // Rows the tenant will try to change: one per catalog object. - await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); - await engine.insert('sys_permission_set', { - id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, - }, { context: system } as any); - await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); - // The member an assignment names: a member of the organization it is scoped to. - await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, - { context: { isSystem: true, tenantId: ORG } } as any); - - const tenant = walled - ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } - : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; - return { engine, tenant, system }; -} - -async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { - try { - await run; - return { ok: true }; - } catch (e) { - const r = resolveThrownHttpError(e); - return { ok: false, code: r.code, status: r.status }; - } -} - -const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; - -async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); - return Array.isArray(rows) ? rows.length : 0; -} - -for (const posture of ['isolated', 'group'] as const) { - describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { - it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); - expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); - expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); - }); - - it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) - .toEqual(REFUSED); - const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; - expect(row?.label).toBe('Existing'); - expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); - }); - - it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) - .toEqual({ ok: true }); - }); - - it('the platform operator rung and a system write are admitted', async () => { - const { engine, tenant, system } = await boot(posture); - const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) - .toEqual({ ok: true }); - }); - - it('a tenant rung below the operator is refused like a context that carries none', async () => { - const { engine, tenant } = await boot(posture); - const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) - .toEqual(REFUSED); - }); - }); -} - -describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { - it('the same administrator creates a position and a permission set', async () => { - const { engine, tenant } = await boot('single'); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); - }); -}); - -describe('the judged objects', () => { - it('are exactly the three security catalog objects — assignment objects are never judged', () => { - expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', - }); - }); -}); diff --git a/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts index 0c9642cea00..adb8b0fa730 100644 --- a/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts @@ -38,6 +38,7 @@ import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ const CAP = 'manage_mailboxes'; @@ -107,8 +108,10 @@ describe('[#22870] the inbound-mail pool is restricted to mailbox owners and adm const userIdOf = async (email: string) => String((await atRest('sys_user', { email }))[0]?.id ?? ''); uid.admin = await userIdOf('admin@objectos.ai'); const grant = async (who: Who, setName: string) => { - const [set] = await atRest('sys_permission_set', { name: setName }); - expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + // [ADR-0131 D3] A set is served by name from the security catalog; no + // boot writes a `sys_permission_set` row for it. + const set = await catalogPermissionSet(ql, setName); + expect(set, `fixture permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: set.name }, { context: { ...SYS } }); }; for (const who of ['reader', 'owner', 'holder', 'outsider'] as const) { diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 2f38d83196e..54813c3396a 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -22,8 +22,10 @@ // Pinned here: // - walled: org A's catalog writes at `/meta` (save, create, delete) answer // `403 FORBIDDEN`, and B's resolution does not move; -// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, -// and nothing reaches the environment ledger; +// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED` +// while the catalog row objects are registered, and `404 OBJECT_NOT_FOUND` +// once C3 stage 8 retires them; either way nothing reaches the environment +// ledger; // - walled: the permission-set clone door refuses org A a clone // `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own @@ -47,6 +49,7 @@ const SYS = { isSystem: true, positions: [], permissions: [] }; interface Engine { find(object: string, options?: unknown): Promise>>; insert(object: string, data: Record, options?: unknown): Promise; + getObject?(object: string): unknown; } /** The ADR-0112 envelope a refusal answered: `error.code` and the HTTP status. */ @@ -169,14 +172,24 @@ for (const posture of ['isolated', 'group'] as const) { expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); }); - it('the data door refuses org A a catalog create 403 PERMISSION_DENIED, and nothing reaches the environment ledger', async () => { + it('the data door refuses org A a catalog create, and nothing reaches the environment ledger', async () => { + // While the catalog row objects are registered the walled data-door + // refusal answers; once C3 stage 8 retires them (ADR-0131 D3) the data + // door serves no such object at all. + const refusal = (object: string) => (ql.getObject?.(object) + ? { status: 403, code: 'PERMISSION_DENIED' } + : { status: 404, code: 'OBJECT_NOT_FOUND' }); + const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); - expect(await envelopeOf(set)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(await envelopeOf(set)).toEqual(refusal('sys_permission_set')); expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); - expect(await envelopeOf(position)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); - expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + expect(await envelopeOf(position)).toEqual(refusal('sys_position')); + expect((await definitionOf('position', 'w_dd_pos'))?.item).toBeFalsy(); + if (ql.getObject?.('sys_position')) { + expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + } }); it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { From ef8dba8d2e309518bb44533f054a0ac43c901f7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:19:18 +0000 Subject: [PATCH 21/39] chore(regen): regenerate the artifacts the main merge deferred, on the merged tree The merge took main's side of every generated file; regenerated here from the merged sources (main + stage 8): - plugin-security en object translations (check-i18n-bundles --write; the other locales and every other bundle were already in sync), - the spec reference page for permission (gen:docs), - the platform-object tenancy census (four retired objects leave it), - the tenant-audit census page and its counts ledger. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../docs/permissions/tenant-audit-census.mdx | 14 +- .../docs/references/security/permission.mdx | 2 +- ...08-tenant-audit-write-call-sites.counts.md | 16 +- .../src/translations/en.objects.generated.ts | 304 +----------------- scripts/platform-object-tenancy-census.json | 32 +- 5 files changed, 19 insertions(+), 349 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 96542330024..8e8fd4004f3 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -226,7 +226,7 @@ cannot read, and they are neither in nor out. | write call sites on the application surface | **225** | | …whose object name is statically decidable | 155 | | …whose object name is chosen at run time | 70 | -| …against an object with tenancy ENABLED | 154 | +| …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 164 | | PROVABLY carrying none (options read, no context key) | **8** | @@ -240,9 +240,9 @@ cannot read, and they are neither in nor out. | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 183 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 27 | | receiver erased, placed by an `object: string` PARAMETER | 7 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 8 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-12 at `c60e08810`. +Measured on 2026-10-13 at `c211fdd41`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 629 | -| engine-shaped types recognised | 71 | -| declared objects in the registry | 117 | +| tracked non-test sources scanned | 642 | +| engine-shaped types recognised | 72 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index e3895711ec6..4d28e9bd36c 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -116,7 +116,7 @@ const result = AdminScopeSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) | | **label** | `string` | optional | Display label | -| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) | +| **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) | | **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | | **isDefault** | `boolean` | optional (default: `false`) | [ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 6a61d0062ff..389d388e1e6 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -36,7 +36,7 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Write call sites | 225 | | Object name statically decidable | 155 | | Object name chosen at run time | 70 | -| Against a tenancy-enabled object | 154 | +| Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 164 | | Provably carrying none | 8 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-12 at `c60e08810`. +Measured on 2026-10-13 at `c211fdd41`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 629 | -| engine-shaped types recognised | 71 | -| declared objects in the registry | 117 | +| tracked non-test sources scanned | 642 | +| engine-shaped types recognised | 72 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 161 | ## Every site @@ -163,10 +163,10 @@ Measured on 2026-10-12 at `c60e08810`. | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | undeclared-name | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index e2cbd494c0b..d7314fdec3e 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -15,279 +15,6 @@ import type { TranslationData } from '@objectstack/spec/system'; export const enObjects: NonNullable = { - sys_position: { - label: "Position", - pluralLabel: "Positions", - description: "Position definitions for capability distribution (ADR-0090)", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the position, unique per organization (e.g. sales_manager, hr_specialist)" - }, - description: { - label: "Description" - }, - active: { - label: "Active" - }, - is_default: { - label: "Default Position", - help: "Automatically assigned to new users" - }, - delegatable: { - label: "Delegatable", - help: "ADR-0091 D3: holders may self-service delegate this position, time-boxed." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: platform = framework built-in (read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows may carry system (== platform) / config (== package) / user (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - id: { - label: "Position ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - default_positions: { - label: "Default" - }, - custom: { - label: "Custom" - }, - all_positions: { - label: "All" - } - }, - _actions: { - activate_position: { - label: "Activate Position", - successMessage: "Position activated" - }, - deactivate_position: { - label: "Deactivate Position", - confirmText: "Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.", - successMessage: "Position deactivated" - }, - set_default_position: { - label: "Set as Default", - confirmText: "Make this the default position for new users? Existing users are unaffected.", - successMessage: "Default position updated" - }, - clone_position: { - label: "Clone Position", - successMessage: "Position cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - }, - _validations: { - reserved_identity_name: { - message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." - } - } - }, - sys_capability: { - label: "Capability", - pluralLabel: "Capabilities", - description: "Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access)." - }, - description: { - label: "Description" - }, - scope: { - label: "Scope", - help: "platform = a platform-wide power; org = scoped to an organization.", - options: { - platform: "Platform", - org: "Organization" - } - }, - managed_by: { - label: "Managed By", - help: "platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - package_id: { - label: "Owning Package", - help: "Package that ships this capability (absent = platform-curated or admin-created)." - }, - active: { - label: "Active", - help: "Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing." - }, - id: { - label: "Capability ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - platform: { - label: "Platform" - }, - org: { - label: "Organization" - }, - all_capabilities: { - label: "All" - } - }, - _actions: { - activate_capability: { - label: "Activate", - successMessage: "Capability activated" - }, - deactivate_capability: { - label: "Deactivate", - confirmText: "Deactivate this capability? This is a catalogue flag only: it marks the row inactive for filtering and review in Setup. Authorization is NOT affected — permission sets that grant this capability, and resources that require it, match it by name and keep resolving exactly as before.", - successMessage: "Capability deactivated" - } - } - }, - sys_permission_set: { - label: "Permission Set", - pluralLabel: "Permission Sets", - description: "Named permission groupings for fine-grained access control", - fields: { - label: { - label: "Display Name" - }, - name: { - label: "API Name", - help: "Machine name for the permission set, unique per organization. This is the set’s metadata identity (ADR-0094) and cannot be changed after creation — the data door rejects a rename; clone the set to a new name instead." - }, - description: { - label: "Description" - }, - object_permissions: { - label: "Object Permissions", - help: "JSON-serialized object-level CRUD permissions" - }, - field_permissions: { - label: "Field Permissions", - help: "JSON-serialized field-level read/write permissions" - }, - system_permissions: { - label: "System Permissions", - help: "JSON-serialized array of system capability names (e.g. [\"setup.access\",\"studio.access\",\"manage_users\"])" - }, - row_level_security: { - label: "Row-Level Security", - help: "JSON-serialized array of row-level security policies (USING/CHECK clauses)" - }, - tab_permissions: { - label: "Tab Permissions", - help: "JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)" - }, - admin_scope: { - label: "Delegated Admin Scope", - help: "[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the user a SCOPED administrator within the declared business-unit subtree; enforced by the delegated-admin write gate." - }, - active: { - label: "Active" - }, - package_id: { - label: "Owning Package", - help: "Package that ships this permission set (absent = environment-authored). Set on rows an earlier release seeded for a package's set; no current writer sets it." - }, - managed_by: { - label: "Managed By", - help: "Record provenance, on the one platform / package / admin vocabulary that capabilities, permission sets and positions all share: 'platform' = shipped by the platform; 'package' = a row an earlier release seeded for a package's set (read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited). Legacy rows may carry 'user' (== admin).", - options: { - platform: "Platform", - package: "Package", - admin: "Admin" - } - }, - customized: { - label: "Customized", - help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." - }, - id: { - label: "Permission Set ID" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - inactive: { - label: "Inactive" - }, - all_permsets: { - label: "All" - } - }, - _actions: { - activate_permission_set: { - label: "Activate", - successMessage: "Permission set activated" - }, - deactivate_permission_set: { - label: "Deactivate", - confirmText: "Deactivate this permission set? Existing assignments stay in place but stop granting access until re-activated.", - successMessage: "Permission set deactivated" - }, - clone_permission_set: { - label: "Clone", - description: "Copies this set's permissions into a new organization-owned set you can edit. Delegated-admin scope is not copied — grant it deliberately on the new set if it needs one.", - successMessage: "Permission set cloned", - params: { - label: { - label: "New Display Name" - }, - name: { - label: "New API Name", - helpText: "snake_case machine name, unique per organization" - } - } - } - } - }, sys_user_permission_set: { label: "User Permission Set", pluralLabel: "User Permission Sets", @@ -307,7 +34,7 @@ export const enObjects: NonNullable = { }, permission_set_id: { label: "Permission Set (legacy id)", - help: "[ADR-0131 D10] Legacy id reference to a sys_permission_set row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." + help: "[ADR-0131 D10] Legacy id of a retired permission-set catalog row, dropped with its data by a later stage. Not written and not read to grant anything: the grant names its set by permission_set." }, permission_set: { label: "Permission Set", @@ -354,35 +81,6 @@ export const enObjects: NonNullable = { } } }, - sys_position_permission_set: { - label: "Position Permission Set", - pluralLabel: "Position Permission Sets", - description: "Binds a permission set to a position.", - fields: { - id: { - label: "Binding ID", - help: "UUID of the position-permission-set binding." - }, - display_title: { - label: "Title", - help: "Record title: the position and the permission set bound to it (computed on read)" - }, - position_id: { - label: "Position", - help: "Foreign key to sys_position." - }, - permission_set_id: { - label: "Permission Set", - help: "Foreign key to sys_permission_set." - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - } - }, sys_user_position: { label: "User Position", pluralLabel: "User Positions", diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 0ca3b874695..cccca4aa004 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 87, - "inReach": 52, + "registered": 83, + "inReach": 48, "outOfReach": 35 }, "reasonTotals": { @@ -140,13 +140,6 @@ "tenantField": "organization_id", "reasons": [] }, - { - "name": "sys_capability", - "file": "packages/plugins/plugin-security/src/objects/sys-capability.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_comment", "file": "packages/plugins/plugin-audit/src/objects/sys-comment.object.ts", @@ -472,13 +465,6 @@ "managedBy: 'better-auth'" ] }, - { - "name": "sys_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_platform_setting", "file": "packages/platform-objects/src/system/sys-platform-setting.object.ts", @@ -488,20 +474,6 @@ "systemFields.tenant: false" ] }, - { - "name": "sys_position", - "file": "packages/plugins/plugin-security/src/objects/sys-position.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, - { - "name": "sys_position_permission_set", - "file": "packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] - }, { "name": "sys_presence", "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts", From 9af05b51bff76ae0a9b3a607293c3abfe5304062 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:22:35 +0000 Subject: [PATCH 22/39] docs(plugin-security): the published README stops naming the retired catalog objects The README ships in the package (files[]), and stage 8 makes three of its sentences false: sets resolve against SysPermissionSet metadata, SysPosition and SysPermissionSet are exported objects, and sys_position / sys_permission_set are system objects the plugin registers. It now names the security catalog as the home of positions, permission sets and capabilities (ADR-0131 D3), and lists the three objects the plugin still registers. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- packages/plugins/plugin-security/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index 04e20c0fb58..b48b9c7cd73 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -9,7 +9,7 @@ `plugin-security` hooks into the ObjectQL pipeline and applies authorization on every read and write: -1. **Resolve permission sets** — expand the user's positions and direct grants against `SysPermissionSet` metadata. +1. **Resolve permission sets** — expand the user's positions and direct grants against the permission sets the security catalog serves by name. 2. **Check object CRUD** — `allowRead`, `allowCreate`, `allowEdit`, `allowDelete`. 3. **Inject RLS** — compile row-level policy expressions into query filters. 4. **Mask fields** — remove non-readable fields from results; flag non-editable fields on writes. @@ -74,18 +74,21 @@ In CLI / dev-server mode the `OS_MULTI_ORG_ENABLED` environment variable (defaul | `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across the held permission sets (most-permissive merge). | | `RLSCompiler` | class | Compiles RLS expressions into ObjectQL filter AST. | | `FieldMasker` | class | Strips non-readable fields and identifies non-editable ones. | -| `SysPosition`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. | +| `SysUserPosition`, `SysUserPermissionSet` | objects | Assignment objects registered by the plugin. | ## System objects +Positions, permission sets and capabilities are not objects: they live in the security catalog, fed by package declarations and by environment metadata saved through `/api/v1/meta/position`, `/api/v1/meta/permission` and `/api/v1/meta/capability` (ADR-0131 D3). A position names its permission sets in its definition's `permissionSets`. + The plugin contributes these system objects to the kernel: | Object | Purpose | |:---|:---| -| `sys_position` | Position (岗位) definitions — the flat permission-set distribution layer (ADR-0090 D3). | -| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions and a delegated-admin `admin_scope` (ADR-0090 D12). | +| `sys_user_position` | Assigns a position (岗位) to a user, by the position's name (ADR-0090 D3). | +| `sys_user_permission_set` | Grants a permission set to a user directly, by the set's name (ADR-0131 D4). | +| `sys_audience_binding_suggestion` | A package's install-time suggestion to grant one of its permission sets to the `everyone` anchor, and the decision on it (ADR-0090 D5/D9). | -Assignment tables (position ↔ user, position ↔ permission_set, user ↔ permission_set) are registered alongside and governed by the delegated-admin and audience-anchor gates. +The assignment objects are governed by the delegated-admin and audience-anchor gates. ## RLS expression language From b3f643b6dcbc2929cacd1a36508e55f5fb77f1c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:36:55 +0000 Subject: [PATCH 23/39] docs(changeset): stage 8's changeset states the uninstall and walled-door behaviour on D's landed form - metadata-protocol is a patch (refusal prose only): the uninstall capture is D's captureUninstallDeclared, so stage 8 exports nothing new there, and cloud-connection is no longer touched. - The uninstall bullet says what stage 8 changes on top of D: the cleanup revokes by the declared names alone and reads or removes no catalog row. - The walled catalog refusal's data-door half goes with the objects: a data-door write on the three retired names answers 404 OBJECT_NOT_FOUND (protocol.assertObjectRegistered) for every caller. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .changeset/15204-s8-catalog-row-objects-retired.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/15204-s8-catalog-row-objects-retired.md b/.changeset/15204-s8-catalog-row-objects-retired.md index 7f2269519a0..0c375a9c2e2 100644 --- a/.changeset/15204-s8-catalog-row-objects-retired.md +++ b/.changeset/15204-s8-catalog-row-objects-retired.md @@ -3,8 +3,7 @@ '@objectstack/platform-objects': minor '@objectstack/spec': minor '@objectstack/core': minor -'@objectstack/metadata-protocol': minor -'@objectstack/cloud-connection': patch +'@objectstack/metadata-protocol': patch '@objectstack/objectql': patch '@objectstack/plugin-sharing': patch '@objectstack/lint': patch @@ -35,7 +34,8 @@ What else changes: - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. -- Uninstalling a package revokes every grant naming a permission set the package declared. The uninstalling door reads those names off the package before withdrawing it and hands them to each uninstall cleanup as `declared` (`UninstallCleanup`, `@objectstack/metadata-protocol`; `declaredCatalogItemsOf` is exported). +- Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. +- On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. Removed exports, no replacement: From 31933c0ccfac9305fc6d1a4760d37fa377517134 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:37:54 +0000 Subject: [PATCH 24/39] =?UTF-8?q?docs(changeset):=20stage=208=20declares?= =?UTF-8?q?=20Clause-=E2=91=A1=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On D's landed form stage 8 adds no export and widens no accept set: the one widening the early build carried, the declaredCatalogItemsOf export, is D's captureUninstallDeclared now. What remains narrows (routes, object pages, exports, the walled data door's admitted operator writes, the lint seed-row source), so the value is no and the arm is narrowing. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .changeset/15204-s8-catalog-row-objects-retired.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15204-s8-catalog-row-objects-retired.md b/.changeset/15204-s8-catalog-row-objects-retired.md index 0c375a9c2e2..5dfdb6a8447 100644 --- a/.changeset/15204-s8-catalog-row-objects-retired.md +++ b/.changeset/15204-s8-catalog-row-objects-retired.md @@ -12,7 +12,7 @@ feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) -Clause-②: yes (narrowing) +Clause-②: no (narrowing) From c0dc292ae8128739a1ad026bdac654ee665a7ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:51:10 +0000 Subject: [PATCH 25/39] test: the suites main's D and wall changes touched span stage 8 as well Measured on a local main + S8-T + S8 tree: - plugin-auth's two grant-reader suites seed the legacy platform-bucket rows only where sys_permission_set is still registered; - builtin-positions expects the walled tenant create refusal while the plugin declares the sys_position row object, which stage 8 retires together with that refusal; - the record-page label-map floor is 38, the count with B2's two maps and the retired position page gone. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../cli/test/platform-page-i18n-parity.test.ts | 2 +- .../src/grant-readers-by-name.golden.test.ts | 5 +++-- .../src/grant-readers-unnamed-grant.test.ts | 7 +++++-- .../src/builtin-positions.boot.test.ts | 16 ++++++++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 95b0bd6a671..3f38f799385 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -842,7 +842,7 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // that the pages went monolingual. [#15204 B2] The user page's two grant // lists dropped their `add` pickers, and with them two label maps. // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) - expect(maps.length).toBeGreaterThanOrEqual(39); + expect(maps.length).toBeGreaterThanOrEqual(38); const incomplete = maps .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 2015e096873..ffbd50b6fbe 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -154,8 +154,9 @@ async function standingByReader(posture: Posture): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } - // An existing database: the platform-bucket rows an earlier release seeded. - await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); + // An existing database: the platform-bucket rows an earlier release seeded, + // where the row object is still registered (C3 stage 8 retires it). + if (engine.getObject?.('sys_permission_set')) { + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); + } const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine }; diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index c7c467362df..b16e0290c1f 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -573,15 +573,23 @@ const REFUSAL_GOLDEN: Record = { }, }; +/** + * Whether the plugin still declares the `sys_position` row object. C3 stage 8 + * retires it (ADR-0131 D3) and unregisters the walled data-door refusal with + * it; this harness registers the legacy table itself either way. + */ +const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); + /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused (`walled-catalog-data-door.ts`): the catalog is the platform - * operator's, and a tenant may only assign it. The assignment cases do not - * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. + * refused (`walled-catalog-data-door.ts`) while the plugin declares the row + * object: the catalog is the platform operator's, and a tenant may only assign + * it. The assignment cases do not move, and the built-in row refusals keep + * their `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const golden: Record = scenario.posture === 'single' + const golden: Record = scenario.posture === 'single' || !PLUGIN_DECLARES_POSITION_ROWS ? REFUSAL_GOLDEN : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; if (scenario.existingRows) return golden; From d426f922eda9787b28bea0274138bb3fecb84ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:46:11 +0000 Subject: [PATCH 26/39] test(plugin-security): the walled builtin-positions golden leaves the data-door create out Seat ruling: the walled scenarios no longer judge a tenant's create of the hand-registered legacy sys_position row. That data-door refusal retires with the object in stage 8, as walled-catalog-data-door.test.ts did, and the walled create refusal is pinned at the /meta wall (wall-catalog-refusal.dogfood.test.ts). Replaces the previous commit's declares-the-object switch; every other key of the golden is unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/builtin-positions.boot.test.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index b16e0290c1f..a201c11965e 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,7 +403,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, walled: boolean): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; @@ -412,13 +412,17 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean): Pr }; const everyone = await own('everyone'); const platformAdmin = await own('platform_admin'); + const dangling = await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, + )); + const created = await outcome(engine.insert( + 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, + )); return { - 'assign a position the security catalog does not hold': await outcome(engine.insert( - 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, - )), - 'control: create a position row': await outcome(engine.insert( - 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, - )), + 'assign a position the security catalog does not hold': dangling, + // Under a wall the tenant's create is not judged here: the walled create + // refusal is pinned at the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). + ...(walled ? {} : { 'control: create a position row': created }), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -529,7 +533,8 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { // A fresh database holds no built-in row to delete or relabel; the two // refusals are read where the rows are, on an existing database. const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows)).toEqual(refusalGoldenFor(scenario)); + expect(await tenantPositionWrites(engine, scenario.existingRows, scenario.posture !== 'single')) + .toEqual(refusalGoldenFor(scenario)); }); } }); @@ -541,7 +546,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is refused as well + * provenance. Under a wall the tenant's position create is not judged here * ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { @@ -573,25 +578,18 @@ const REFUSAL_GOLDEN: Record = { }, }; -/** - * Whether the plugin still declares the `sys_position` row object. C3 stage 8 - * retires it (ADR-0131 D3) and unregisters the walled data-door refusal with - * it; this harness registers the legacy table itself either way. - */ -const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); - /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused (`walled-catalog-data-door.ts`) while the plugin declares the row - * object: the catalog is the platform operator's, and a tenant may only assign - * it. The assignment cases do not move, and the built-in row refusals keep - * their `403 PERMISSION_DENIED`. + * database's rows. [ADR-0131 D3] Under a wall the tenant's position create is + * left out: the data-door create refusal pinned here retires with the + * `sys_position` object (C3 stage 8), and the walled create refusal is pinned at + * the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). The assignment + * cases do not move, and the built-in row refusals keep their + * `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const golden: Record = scenario.posture === 'single' || !PLUGIN_DECLARES_POSITION_ROWS - ? REFUSAL_GOLDEN - : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; + const { 'control: create a position row': _create, ...walledGolden } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' ? REFUSAL_GOLDEN : walledGolden; if (scenario.existingRows) return golden; const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; From 08524a15633ffea4aaee1e0242ba491a77ef1be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:54:49 +0000 Subject: [PATCH 27/39] test(plugin-auth): the grant-reader suites register the retired sys_permission_set table before seeding its bucket rows Seat ruling: the two suites model an existing database whose platform-bucket rows outlive the object declaration, so each registers the legacy sys_permission_set table shape (a test-only copy under src/__tests__, the shape plugin-security's testkit holds) whenever the plugin no longer declares it, and seeds the rows unconditionally. Replaces the previous commit's skip of the seed after stage 8; the walled organization copies and the legacy-id write read their rows on both trees again, as on main. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../legacy-catalog-objects.testkit.ts | 69 +++++++++++++++++++ .../src/grant-readers-by-name.golden.test.ts | 14 ++-- .../src/grant-readers-unnamed-grant.test.ts | 22 +++--- 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts new file mode 100644 index 00000000000..a24cdcdb93a --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the TABLE shape of the retired `sys_permission_set` row + * object (ADR-0131 D3; retired from plugin-security by C3 stage 8), for a suite + * that models an EXISTING database whose platform-bucket rows + * (`legacy-catalog-rows.testkit.ts`) outlive the object declaration until the + * organization-ownership migration (ADR-0131 D10) takes them. + * + * The columns are the ones those rows carry, as the last release that declared + * the object stored them. A test-only copy of plugin-security's + * `src/__tests__/legacy-catalog-objects.testkit.ts` shape rather than an import + * of it: that file is no export of the package, and a relative import out of + * this package would make a test read another package's source. + */ + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const SysPermissionSet = ObjectSchema.create({ + name: 'sys_permission_set', + label: 'Permission Set', + pluralLabel: 'Permission Sets', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ label: 'Display Name', required: true, maxLength: 255 }), + name: Field.text({ label: 'API Name', required: true, maxLength: 100 }), + description: Field.textarea({ label: 'Description', required: false }), + object_permissions: Field.textarea({ label: 'Object Permissions', required: false }), + field_permissions: Field.textarea({ label: 'Field Permissions', required: false }), + system_permissions: Field.textarea({ label: 'System Permissions', required: false }), + row_level_security: Field.textarea({ label: 'Row-Level Security', required: false }), + tab_permissions: Field.textarea({ label: 'Tab Permissions', required: false }), + admin_scope: Field.textarea({ label: 'Admin Scope', required: false }), + active: Field.boolean({ label: 'Active', defaultValue: true }), + package_id: Field.text({ label: 'Owning Package', required: false, maxLength: 255 }), + managed_by: Field.select({ + label: 'Managed By', + required: false, + defaultValue: 'admin', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + }), + customized: Field.boolean({ label: 'Customized', defaultValue: false }), + id: Field.text({ label: 'Permission Set ID', required: true, readonly: true }), + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true }), + updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true }), + }, + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], +}); + +/** + * `objects` plus the retired `sys_permission_set` table when it does not + * already declare one — for a suite that boots the security plugin's objects + * AND models an existing database's bucket rows, whether or not the plugin + * still declares the table. + */ +export function withLegacyPermissionSetTable(objects: readonly T[]): Array { + return objects.some((o) => o?.name === SysPermissionSet.name) ? [...objects] : [...objects, SysPermissionSet]; +} diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index ffbd50b6fbe..ec3d3c36520 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -41,6 +41,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; +import { withLegacyPermissionSetTable } from './__tests__/legacy-catalog-objects.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -128,7 +129,9 @@ async function standingByReader(posture: Posture): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [ + // The retired `sys_permission_set` table too: an existing database still + // holds its platform-bucket rows (C3 stage 8 retires the object). + objects: withLegacyPermissionSetTable([ SysUser, SysAccount, SysMember, SysOrganization, ...(manifest.objects ?? []).filter((o: any) => ['sys_position', 'sys_user_position', 'sys_permission_set', 'sys_position_permission_set', 'sys_user_permission_set'] .includes(o?.name)), - ], + ]), } as any); await engine.syncSchemas(); await plugin.start(ctx); @@ -123,11 +126,8 @@ async function soleGrantAnchoredAdmin(): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } - // An existing database: the platform-bucket rows an earlier release seeded, - // where the row object is still registered (C3 stage 8 retires it). - if (engine.getObject?.('sys_permission_set')) { - await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); - } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine }; @@ -178,12 +178,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the last-administrator gu it('writing the legacy id moves no standing: nothing derives a name from it, so the write is permitted', async () => { const { engine } = await soleGrantAnchoredAdmin(); const grant = await adminGrant(engine); - // The id of another set's legacy catalog row — read off the row where that - // object is still registered, a literal one once it is retired (C3 stage 8). - const viewerId = engine.getObject?.('sys_permission_set') - ? ((await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[])[0]?.id - : 'ps_legacy_viewer_readonly'; - await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewerId, reason: 'renewed' }, SYS); + const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[]; + await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id, reason: 'renewed' }, SYS); expect(await adminGrant(engine)).toMatchObject({ permission_set: 'admin_full_access', reason: 'renewed' }); }); }); From e40fe491c1ae73ffe48e606877cd794a8d6b11fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:11:03 +0000 Subject: [PATCH 28/39] test(plugin-auth): the legacy-table helper takes the manifest's untyped object list The plugin's manifest hands its objects as unknown[], so the helper reads each entry's name defensively; the test layer compiles again under tsconfig.test.json (check:test-typecheck). Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/__tests__/legacy-catalog-objects.testkit.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts index a24cdcdb93a..064b9742115 100644 --- a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts @@ -64,6 +64,7 @@ export const SysPermissionSet = ObjectSchema.create({ * AND models an existing database's bucket rows, whether or not the plugin * still declares the table. */ -export function withLegacyPermissionSetTable(objects: readonly T[]): Array { - return objects.some((o) => o?.name === SysPermissionSet.name) ? [...objects] : [...objects, SysPermissionSet]; +export function withLegacyPermissionSetTable(objects: readonly unknown[]): unknown[] { + const declared = objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === SysPermissionSet.name); + return declared ? [...objects] : [...objects, SysPermissionSet]; } From 9d9bdd96b2457b51ff32a86c3928c73c5771ad98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:18:42 +0000 Subject: [PATCH 29/39] test(plugin-security): restore the walled data-door suite; builtin-positions judges the walled create while sys_position is declared Patch round 1 (contract review on e40fe491c1, seat ruling): - walled-catalog-data-door.test.ts is restored with main's bytes: five of its six cases have no other pin until stage 8 removes the door, and the deletion clause asks for one. Stage 8 deletes it together with the door. - builtin-positions.boot keeps the walled `control: create a position row` key (403 PERMISSION_DENIED) wherever the plugin declares sys_position, and leaves it out only once the object is retired. Its comments name the door that refuses the create: walled-catalog-data-door.ts, pinned by that suite and by wall-catalog-refusal's data-door case. Every other key unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/builtin-positions.boot.test.ts | 45 ++-- .../src/walled-catalog-data-door.test.ts | 227 ++++++++++++++++++ 2 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a201c11965e..454884d61ad 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,7 +403,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, walled: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, judgeCreate: boolean): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; @@ -420,9 +420,10 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, wal )); return { 'assign a position the security catalog does not hold': dangling, - // Under a wall the tenant's create is not judged here: the walled create - // refusal is pinned at the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). - ...(walled ? {} : { 'control: create a position row': created }), + // Judged wherever the plugin declares the `sys_position` row object (under a + // wall, the walled data-door refusal's: `walled-catalog-data-door.ts`); left + // out once C3 stage 8 retires the object and that door together. + ...(judgeCreate ? { 'control: create a position row': created } : {}), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -533,7 +534,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { // A fresh database holds no built-in row to delete or relabel; the two // refusals are read where the rows are, on an existing database. const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows, scenario.posture !== 'single')) + expect(await tenantPositionWrites(engine, scenario.existingRows, judgesCreate(scenario))) .toEqual(refusalGoldenFor(scenario)); }); } @@ -546,8 +547,8 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is not judged here - * ({@link refusalGoldenFor}). + * provenance. Under a wall the tenant's position create is refused as well, + * while the plugin declares the row object ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -578,18 +579,32 @@ const REFUSAL_GOLDEN: Record = { }, }; +/** Whether the plugin still declares the `sys_position` row object; C3 stage 8 retires it (ADR-0131 D3). */ +const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); + +/** Whether a scenario judges the tenant's position create ({@link refusalGoldenFor}). */ +function judgesCreate(scenario: Scenario): boolean { + return scenario.posture === 'single' || PLUGIN_DECLARES_POSITION_ROWS; +} + /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position create is - * left out: the data-door create refusal pinned here retires with the - * `sys_position` object (C3 stage 8), and the walled create refusal is pinned at - * the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). The assignment - * cases do not move, and the built-in row refusals keep their - * `403 PERMISSION_DENIED`. + * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is + * refused by the walled data-door refusal (`walled-catalog-data-door.ts`, also + * pinned by `walled-catalog-data-door.test.ts` and by the data-door case of + * `wall-catalog-refusal.dogfood.test.ts`): the catalog is the platform + * operator's, and a tenant may only assign it. That holds while the plugin + * declares the `sys_position` row object; C3 stage 8 retires the object and that + * door together, and the create is then left out. The assignment cases do not + * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const { 'control: create a position row': _create, ...walledGolden } = REFUSAL_GOLDEN; - const golden: Record = scenario.posture === 'single' ? REFUSAL_GOLDEN : walledGolden; + const { 'control: create a position row': _create, ...withoutCreate } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' + ? REFUSAL_GOLDEN + : PLUGIN_DECLARES_POSITION_ROWS + ? { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } } + : withoutCreate; if (scenario.existingRows) return golden; const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts new file mode 100644 index 00000000000..003a885f3f6 --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the + * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). + * + * Measured before it on this same harness shape (a real `ObjectQL` over a real + * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the + * organization-scoping service): an organization-bound administrator holding + * the superuser wildcard created a position at the data door, and it landed + * as a row of that organization. Nothing judged a catalog create by posture. + * + * Pinned here, per posture: + * - `isolated` and `group`: the tenant's create, update and delete of a + * position, a permission set and a capability are refused with the ADR-0112 + * envelope `403 PERMISSION_DENIED`, and nothing is stored; + * - the CONTROL: the same tenant still assigns a catalog position to a member + * of its organization; + * - the platform operator rung and a system write are admitted; + * - `single`: the same tenant's create is accepted, unchanged. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysCapability } from './objects/sys-capability.object.js'; +import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; + +const ORG = 'org_a'; + +/** The fallback every authenticated caller resolves: it grants nothing here. */ +const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; + +/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +type Posture = 'single' | 'isolated' | 'group'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.walled-catalog-data-door', + name: 'Walled catalog data door', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + SysCapability, SysMember, SysMetadataActivation, + ], + } as any); + await engine.syncSchemas(); + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, QA_ADMIN], + }, + ...(posture === 'single' + ? {} + : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const walled = posture !== 'single'; + const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; + // The catalog the tenant may assign: one position, declared in the registry. + engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, + 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); + // Rows the tenant will try to change: one per catalog object. + await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); + await engine.insert('sys_permission_set', { + id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', + object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }, { context: system } as any); + await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); + // The member an assignment names: a member of the organization it is scoped to. + await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, + { context: { isSystem: true, tenantId: ORG } } as any); + + const tenant = walled + ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } + : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; + return { engine, tenant, system }; +} + +async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { + try { + await run; + return { ok: true }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { ok: false, code: r.code, status: r.status }; + } +} + +const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; + +async function storedNames(engine: ObjectQL, object: string, name: string): Promise { + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); + return Array.isArray(rows) ? rows.length : 0; +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { + it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); + expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); + expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); + }); + + it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) + .toEqual(REFUSED); + const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; + expect(row?.label).toBe('Existing'); + expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); + }); + + it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) + .toEqual({ ok: true }); + }); + + it('the platform operator rung and a system write are admitted', async () => { + const { engine, tenant, system } = await boot(posture); + const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) + .toEqual({ ok: true }); + }); + + it('a tenant rung below the operator is refused like a context that carries none', async () => { + const { engine, tenant } = await boot(posture); + const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) + .toEqual(REFUSED); + }); + }); +} + +describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { + it('the same administrator creates a position and a permission set', async () => { + const { engine, tenant } = await boot('single'); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); + }); +}); + +describe('the judged objects', () => { + it('are exactly the three security catalog objects — assignment objects are never judged', () => { + expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', + }); + }); +}); From e3d00e12ee75336b37cd3f3e41845b35df6825cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:19:31 +0000 Subject: [PATCH 30/39] test(plugin-security): the walled catalog data-door suite goes with the registration it pins walled-catalog-data-door.test.ts boots the real SecurityPlugin with the three catalog row objects and pins the walled data-door refusal's registration on sys_position, sys_permission_set and sys_capability. Stage 8 retires those objects and unregisters that refusal (security-plugin.ts), so the suite goes in the same diff as the code it pins. The walled refusal itself still stands at /meta, the clone door and the /packages doors; the wall-catalog-refusal dogfood pins it there, and pins that a data-door write on a retired name answers 404 OBJECT_NOT_FOUND. The unreferenced module walled-catalog-data-door.ts is left for the dead-module deletions (7a/7b). Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/walled-catalog-data-door.test.ts | 227 ------------------ 1 file changed, 227 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts deleted file mode 100644 index 003a885f3f6..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the - * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). - * - * Measured before it on this same harness shape (a real `ObjectQL` over a real - * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the - * organization-scoping service): an organization-bound administrator holding - * the superuser wildcard created a position at the data door, and it landed - * as a row of that organization. Nothing judged a catalog create by posture. - * - * Pinned here, per posture: - * - `isolated` and `group`: the tenant's create, update and delete of a - * position, a permission set and a capability are refused with the ADR-0112 - * envelope `403 PERMISSION_DENIED`, and nothing is stored; - * - the CONTROL: the same tenant still assigns a catalog position to a member - * of its organization; - * - the platform operator rung and a system write are admitted; - * - `single`: the same tenant's create is accepted, unchanged. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { resolveThrownHttpError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; - -import { SecurityPlugin } from './security-plugin.js'; -import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysCapability } from './objects/sys-capability.object.js'; -import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; - -const ORG = 'org_a'; - -/** The fallback every authenticated caller resolves: it grants nothing here. */ -const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; - -/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -type Posture = 'single' | 'isolated' | 'group'; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(posture: Posture) { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.walled-catalog-data-door', - name: 'Walled catalog data door', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysCapability, SysMember, SysMetadataActivation, - ], - } as any); - await engine.syncSchemas(); - for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { - if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); - } - engines.push(engine); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata: { - get: async (type: string, name: string) => - type === 'permission' - ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async () => [MEMBER_DEFAULT, QA_ADMIN], - }, - ...(posture === 'single' - ? {} - : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), - }; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - hook: vi.fn(), - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - const walled = posture !== 'single'; - const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; - // The catalog the tenant may assign: one position, declared in the registry. - engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, - 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); - // Rows the tenant will try to change: one per catalog object. - await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); - await engine.insert('sys_permission_set', { - id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, - }, { context: system } as any); - await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); - // The member an assignment names: a member of the organization it is scoped to. - await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, - { context: { isSystem: true, tenantId: ORG } } as any); - - const tenant = walled - ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } - : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; - return { engine, tenant, system }; -} - -async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { - try { - await run; - return { ok: true }; - } catch (e) { - const r = resolveThrownHttpError(e); - return { ok: false, code: r.code, status: r.status }; - } -} - -const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; - -async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); - return Array.isArray(rows) ? rows.length : 0; -} - -for (const posture of ['isolated', 'group'] as const) { - describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { - it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); - expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); - expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); - }); - - it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) - .toEqual(REFUSED); - const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; - expect(row?.label).toBe('Existing'); - expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); - }); - - it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) - .toEqual({ ok: true }); - }); - - it('the platform operator rung and a system write are admitted', async () => { - const { engine, tenant, system } = await boot(posture); - const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) - .toEqual({ ok: true }); - }); - - it('a tenant rung below the operator is refused like a context that carries none', async () => { - const { engine, tenant } = await boot(posture); - const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) - .toEqual(REFUSED); - }); - }); -} - -describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { - it('the same administrator creates a position and a permission set', async () => { - const { engine, tenant } = await boot('single'); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); - }); -}); - -describe('the judged objects', () => { - it('are exactly the three security catalog objects — assignment objects are never judged', () => { - expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', - }); - }); -}); From 5bd2d8d79aeb082b659e66a0cfd0de4968816da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:13:06 +0000 Subject: [PATCH 31/39] docs(adr): ADR-0086's D3 status row stops anchoring to the retired sys_permission_set object file Stage 8 deletes packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts, so the row's `...sys-permission-set.object.ts#managedBy` anchor no longer resolves (check-adr-symbol-anchors: unresolved-path). The row now says the row object that carried the record fields is retired, anchored to the ADR-0087 entry that registers the retirement. The spec anchor stays. No other ADR prose changes here; the wider ADR updates follow stage 8. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- ...hz-metadata-config-boundary-and-cross-package-composition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md b/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md index d7340ef913f..7f655be34a9 100644 --- a/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md +++ b/docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md @@ -373,7 +373,7 @@ flagged inline below. |---|---|---| | **D1** classification on the provenance axis | **Adopted**; table vocabulary now stale — see "revised table" below | Principle intact. Rows renamed/removed by ADR-0090 (Profile gone, role→position). | | **D2** DEFINITION↔ASSIGNMENT principle | **Adopted, reinforced** | ADR-0090 D2 leans on it to justify removing Profile; ADR-0091 dates the *assignment* rows only (`valid_from`/`valid_until`), confirming they are env data. | -| **D3** `packageId` + provenance `managedBy` on the set | **Implemented** | `packages/spec/src/security/permission.zod.ts#managedBy` (`packageId`, tagged `[ADR-0086 D3]`) + (`managedBy` enum); record fields `package_id`/`managed_by`/`customized` + index on `packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts#managedBy`. | +| **D3** `packageId` + provenance `managedBy` on the set | **Implemented** | `packages/spec/src/security/permission.zod.ts#managedBy` (`packageId`, tagged `[ADR-0086 D3]`) + (`managedBy` enum); record fields `package_id`/`managed_by`/`customized`; the `sys_permission_set` row object that carried them is retired by C3 stage 8 (ADR-0131 D3; `packages/spec/src/migrations/entries/semantic/18.catalog-row-objects-retired.ts`). | | **D4** Shape B (packages ship own sets; union; shared slices env-only) | **Adopted, reinforced; subtract layer upgraded** | ADR-0090 D9 / Alt #6 reject package-written shared/builtin sets for the same reasons. Composition is now anchored at the **position** ("bind several packages' sets to one position"), ADR-0094 D5. **Subtract layer changed** — see ⚠️ below. | | **D5** `bootstrapDeclaredPermissions` | **Implemented; `isDefault` semantics refined by ADR-0090 D5** | `plugin-security/src/bootstrap-declared-permissions.ts` (invoked at `kernel:ready`, `security-plugin.ts`); seeds `managed_by:'package'` + `package_id`, idempotent/upgrade-aware; closes the ADR-0078 inert-metadata smell the D5 header calls out. `isDefault` no longer means "profile fallback": app-level default sets **auto-bind to the `everyone` position**; package-level default sets materialize a `sys_audience_binding_suggestion` an admin confirms (new object). | | **D6** package Access door under draft/publish | **Implemented** | objectui writes the package door as `mode:'draft', packageId` (`PermissionMatrixEditor.tsx`); env door stays live and, per ADR-0094 D3, that "live" save is itself redirected into a metadata overlay. | From f850dd11b5d16589f8d5418cbc8d217b8bb949e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:55:22 +0000 Subject: [PATCH 32/39] test(plugin-security): the principal-as-person Explain suite takes the retired catalog shapes from the legacy testkit explain-principal-person.test.ts reached main after S8-T and imports SysPosition, SysPermissionSet and SysPositionPermissionSet from the object files stage 8 deletes, so the package's typecheck (check:test-typecheck) and the suite fail on the merged tree. It now imports the same three shapes from __tests__/legacy-catalog-objects.testkit.js, as S8-T's suites do; nothing else in the file changes. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../plugin-security/src/explain-principal-person.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 2a5cac45496..78bf8fba4af 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -30,9 +30,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -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'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; From a29bede970f866eec787ae41b7c89b2b58f7f5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:55:23 +0000 Subject: [PATCH 33/39] test(cli): the unmanaged-table sweep's plugin-declared negative control is a table stage 8 keeps The fixture creates its negative-control tables before the boot, and sys_permission_set was the one "declared by the composed host plugin". Stage 8 retires that object, so its table is now declared by nothing and the sweep rightly reports it (the ADR-0131 D10 state: tables outlive their retired objects until the organization-ownership migration). A fresh boot creates no retired table (measured: an empty sqlite file booted with real DDL holds 36 tables, none of the four). The control becomes sys_user_permission_set, which plugin-security still declares, and the composition witness follows it; the stranded case is unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../cli/src/utils/unmanaged-tables.integration.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/unmanaged-tables.integration.test.ts b/packages/cli/src/utils/unmanaged-tables.integration.test.ts index 6df211de61e..80a8a0a93fb 100644 --- a/packages/cli/src/utils/unmanaged-tables.integration.test.ts +++ b/packages/cli/src/utils/unmanaged-tables.integration.test.ts @@ -22,7 +22,7 @@ * dialects; this exercises the sqlite one through the driver's own raw * seam, against tables that really exist. * - * ⛔ The NEGATIVE control is the point. `sys_permission_set` (from the composed + * ⛔ The NEGATIVE control is the point. `sys_user_permission_set` (from the composed * plugin) and `sys_secret` (from the platform floor) are put in the database * BEFORE the boot and must NOT be reported, alongside `_objectstack_sequences` * and an application table. A sweep observed only in the presence of an orphan @@ -76,7 +76,10 @@ const OVERRIDING_ENV = [ /** Legitimate, and every one of them must stay OUT of the report. */ const NEGATIVE_CONTROLS = [ - 'sys_permission_set', // declared by the composed host plugin + // [ADR-0131 D3] A table the composed plugin still declares. `sys_permission_set` + // was this control until C3 stage 8 retired its object: its table is now + // declared by nothing, so the sweep is RIGHT to report it. + 'sys_user_permission_set', // declared by the composed host plugin 'sys_secret', // declared by the platform floor '_objectstack_sequences', // the driver's own autonumber ledger 'app_leftovers', // no reserved prefix at all @@ -204,7 +207,7 @@ describe('os migrate plan — unmanaged tables, against a real database (#13204) expect(stack!.composition.servedBoot).toEqual({ mirrored: true }); expect(stack!.managedTableCount).toBeGreaterThan(0); const declared = (stack!.allObjects() as Array<{ name?: string }>).map((o) => o?.name); - expect(declared).toContain('sys_permission_set'); + expect(declared).toContain('sys_user_permission_set'); expect(declared).toContain('sys_secret'); }); From 976023f0da021d12a5e02ee7d45ff1cb15a620c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:55:04 +0000 Subject: [PATCH 34/39] test(cli): the host-composition migrate suite pins its drift and coverage on the security tables stage 8 keeps schema-migrate.host-composition.integration.test.ts composed SecurityPlugin and read sys_position / sys_permission_set as the plugin's tables: pending work naming both, cloud#1695's legacy global unique swapped back on sys_position.name, and an empty sys_permission_set as the plan's write absence. Stage 8 retires both objects (ADR-0131 D3), so a fresh composed boot declares neither and five cases failed. - Pending work and the #13028 coverage case now name sys_user_position and sys_user_permission_set, which the plugin still declares. - The legacy-unique cases use sys_audience_binding_suggestion's organization-respelled declared unique (package_id, permission_set_name, anchor): the #8323 arm of the same replace_unique_index plan, measured to plan safe, apply without --allow-destructive and converge. Both index names come from the driver's buildIndexName. - The write absence reads the plugin's remaining tables; the #13028 no-table case checks sys_user_permission_set. The #21054 cases keep sys_position / sys_permission_set as names no composition declares, which still holds. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- ...grate.host-composition.integration.test.ts | 124 +++++++++++------- 1 file changed, 76 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts index 95b33123a45..76487d91cc6 100644 --- a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -8,6 +8,7 @@ import { dirname, join, resolve } from 'node:path'; // Loaded at module top, so the first transform is paid during collection // rather than inside a clocked test body (`check:test-source-alias`). import { AppPlugin } from '@objectstack/runtime'; +import { buildIndexName } from '@objectstack/driver-sql'; import { loadConfig } from './config.js'; import { bootSchemaStack } from './schema-migrate.js'; @@ -24,17 +25,22 @@ import { bootSchemaStack } from './schema-migrate.js'; * * The fixture is cloud#1695's shape, built by the platform rather than by hand: * a host `objectstack.config.ts` composing `SecurityPlugin`, the tables created - * through the migrate path itself, and then `sys_position`'s per-organization - * composite unique swapped back to the pre-#8323 global spelling - * (`uniq_sys_position_name`) — the exact index a deployed control DB was still - * carrying. + * through the migrate path itself, and then a per-organization composite unique + * swapped back to its pre-#8323 global spelling — the drift a deployed control + * DB was carrying. The measured index was `sys_position`'s + * (`uniq_sys_position_name`); C3 stage 8 retired that object (ADR-0131 D3), so + * the swap is now made on the one organization-respelled unique the plugin + * still declares: `sys_audience_binding_suggestion`'s + * `(package_id, permission_set_name, anchor)`, the #8323 declared-index arm of + * the same `replace_unique_index` plan. * * Four things are pinned, and the fourth is an ABSENCE. Composing a host - * config means composing arbitrary code, and `SecurityPlugin` seeds its - * built-in permission sets from `start()`: measured, that produced 14 insert - * attempts against `sys_permission_set` during a run documented as writing - * nothing. `sys_permission_set` staying EMPTY across a plan is what proves the - * declaration-phase composition actually holds. + * config means composing arbitrary code. Measured before the catalog seeders + * retired, `SecurityPlugin.start()` made 14 insert attempts against + * `sys_permission_set` during a run documented as writing nothing. That table + * is retired with its object; the plugin's tables holding exactly the + * fixture's rows across a plan is what still shows the declaration-phase + * composition holds. */ const require_ = createRequire(import.meta.url); @@ -63,6 +69,19 @@ const ARTIFACTLESS_BASELINE_TABLES = [ 'sys_metadata_history', ]; +/** + * The organization-respelled declared unique the drift cases below put back to + * its global spelling: `sys_audience_binding_suggestion`'s + * `(package_id, permission_set_name, anchor)`, declared `unique: 'organization'`. + * Both names are the driver's own (`buildIndexName`), never spelled by hand. + */ +const RESPELLED = { + table: 'sys_audience_binding_suggestion', + listed: ['package_id', 'permission_set_name', 'anchor'], +} as const; +const LEGACY_UNIQUE = buildIndexName(RESPELLED.table, [...RESPELLED.listed], true); +const ORG_UNIQUE = buildIndexName(RESPELLED.table, ['organization_id', ...RESPELLED.listed], true); + describe('os migrate plan/apply compose the deployment\'s own object set (#12938)', () => { let dir: string; let dbFile: string; @@ -147,9 +166,9 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 }; /** - * Put `sys_position` back into the pre-respelling physical shape — the - * per-organization composite retired, the global single-column unique - * present. This is cloud#1695's index, verbatim. + * Put {@link RESPELLED} back into the pre-respelling physical shape — the + * per-organization composite retired, the global unique on the listed + * columns present. cloud#1695's shape, on a table the plugin still declares. * * Idempotent, and called by every test that needs it, so the cases below do * not depend on each other's order. An integration file whose third test only @@ -160,18 +179,20 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 const stack = await bootLikeMigrate(); try { const k = (stack.driver as any).knex; - const present = await indexNames(stack.driver, 'sys_position'); - if (present.includes('uniq_sys_position_organization_id_name')) { - await k.raw('DROP INDEX uniq_sys_position_organization_id_name'); + const present = await indexNames(stack.driver, RESPELLED.table); + if (present.includes(ORG_UNIQUE)) { + await k.raw(`DROP INDEX ${ORG_UNIQUE}`); } - if (!present.includes('uniq_sys_position_name')) { - await k.raw('CREATE UNIQUE INDEX uniq_sys_position_name ON sys_position (name)'); + if (!present.includes(LEGACY_UNIQUE)) { + await k.raw(`CREATE UNIQUE INDEX ${LEGACY_UNIQUE} ON ${RESPELLED.table} (${RESPELLED.listed.join(', ')})`); } - if (await countRows(stack.driver, 'sys_position') === 0) { - await k('sys_position').insert({ - id: 'pos_1', - name: 'org_admin', - label: 'Org Admin', + if (await countRows(stack.driver, RESPELLED.table) === 0) { + await k(RESPELLED.table).insert({ + id: 'abs_1', + package_id: 'com.acme.crm', + permission_set_name: 'sales_readonly', + anchor: 'everyone', + status: 'pending', organization_id: 'org_jia', }); } @@ -196,15 +217,18 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 expect(stack.composition.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); // The number the consumer-side coverage gate reads. Strictly greater than - // the baseline, and by more than one: the security plugin alone brings six - // RBAC tables and the platform floor another four. + // the baseline, and by more than one: the security plugin alone brings + // three tables (its assignment objects and the audience-binding + // suggestions; the catalog row objects retired in C3 stage 8) and the + // platform floor another four. expect(stack.managedTableCount).toBeGreaterThan(ARTIFACTLESS_BASELINE_TABLES.length); const pending = stack.pendingSchemaWork.map((p) => p.table); - // The two tables the issue names by hand, neither of which was reachable - // from a migrate boot at all. - expect(pending).toContain('sys_position'); - expect(pending).toContain('sys_permission_set'); + // The issue named `sys_position` and `sys_permission_set` by hand, neither + // reachable from a migrate boot at all; both objects are retired now + // (ADR-0131 D3), and the security plugin's tables that remain stand in. + expect(pending).toContain('sys_user_position'); + expect(pending).toContain('sys_user_permission_set'); // …and the platform floor `serve` composes unconditionally. expect(pending).toContain('sys_migration'); for (const table of ARTIFACTLESS_BASELINE_TABLES) expect(pending).toContain(table); @@ -225,15 +249,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 try { const drift = await stack.driver!.detectManagedDrift(); const entry = drift.find( - (d) => d.table === 'sys_position' && d.op.type === 'replace_unique_index', + (d) => d.table === RESPELLED.table && d.op.type === 'replace_unique_index', ); expect(entry, 'the legacy platform-wide unique must be planned, not invisible').toBeDefined(); expect(entry!.category).toBe('safe'); expect(entry!.op).toMatchObject({ - table: 'sys_position', - dropIndexNames: ['uniq_sys_position_name'], - createIndexName: 'uniq_sys_position_organization_id_name', - createColumns: ['organization_id', 'name'], + table: RESPELLED.table, + dropIndexNames: [LEGACY_UNIQUE], + createIndexName: ORG_UNIQUE, + createColumns: ['organization_id', ...RESPELLED.listed], }); // The prescription that made this a contract rather than an incomplete report. expect(entry!.message).toContain('os migrate apply'); @@ -249,13 +273,15 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 try { await stack.driver!.detectManagedDrift(); - // The absence that matters. `SecurityPlugin.start()` bootstraps - // `admin_full_access`, `organization_admin`, `member_default` and the rest; - // fully started, it attempted 14 inserts into this table during a plan. - expect(await countRows(stack.driver, 'sys_permission_set')).toBe(0); - expect(await countRows(stack.driver, 'sys_position')).toBe(1); + // The absence that matters: the security plugin's tables hold exactly + // the fixture's rows. (Fully started, `SecurityPlugin.start()` once made + // 14 insert attempts into `sys_permission_set` during a plan; that seeder + // and that object are retired, ADR-0131 D3.) + expect(await countRows(stack.driver, 'sys_user_permission_set')).toBe(0); + expect(await countRows(stack.driver, 'sys_user_position')).toBe(0); + expect(await countRows(stack.driver, RESPELLED.table)).toBe(1); // The legacy index is still there — a plan proposes, it does not apply. - expect(await indexNames(stack.driver, 'sys_position')).toContain('uniq_sys_position_name'); + expect(await indexNames(stack.driver, RESPELLED.table)).toContain(LEGACY_UNIQUE); } finally { await stack.shutdown(); } @@ -274,10 +300,10 @@ describe('os migrate plan/apply compose the deployment\'s own object set (#12938 expect(applied.some((d) => d.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); - const after = await indexNames(stack.driver, 'sys_position'); - expect(after).toContain('uniq_sys_position_organization_id_name'); - expect(after).not.toContain('uniq_sys_position_name'); - expect(await countRows(stack.driver, 'sys_position')).toBe(1); + const after = await indexNames(stack.driver, RESPELLED.table); + expect(after).toContain(ORG_UNIQUE); + expect(after).not.toContain(LEGACY_UNIQUE); + expect(await countRows(stack.driver, RESPELLED.table)).toBe(1); } finally { await stack.shutdown(); } @@ -464,11 +490,13 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur // being a second, differently-derived number. expect(stack.managedTableCount).toBe(coverage!.examinedObjects); - // The two tables #12938 named by hand as the proof the five-table set was - // wrong. Pre-#13028 this shape reached NEITHER. + // #12938 named `sys_position` and `sys_permission_set` by hand as the proof + // the five-table set was wrong; pre-#13028 this shape reached neither. Both + // objects are retired now (ADR-0131 D3), so the security plugin's tables + // that remain carry the same proof. const pending = stack.pendingSchemaWork.map((p) => p.table); - expect(pending).toContain('sys_position'); - expect(pending).toContain('sys_permission_set'); + expect(pending).toContain('sys_user_position'); + expect(pending).toContain('sys_user_permission_set'); // Full coverage means SILENCE about coverage: the honesty note exists to // mark a shortfall, and one that printed anyway would train readers to @@ -493,7 +521,7 @@ describe('a host that brings its OWN ObjectQL engine (#13028 — cloud\'s measur // assignment on the driver. If it had reached `initObjects` instead, the // table would exist here. const k = (stack.driver as any).knex; - const exists = await k.schema.hasTable('sys_permission_set'); + const exists = await k.schema.hasTable('sys_user_permission_set'); expect(exists, 'a plan must not create a table on its way to a coverage number').toBe(false); } finally { await stack.shutdown(); From d9f53a69e538522a09aaa11f3d6297bb6781066b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:40:59 +0000 Subject: [PATCH 35/39] fix(spec): the walled-catalog ADR-0087 entry stops promising a data-door 403 on the retired catalog objects 18.walled-catalog-tenant-writes-retired's surface named a data-door insert, update or delete on sys_position, sys_permission_set and sys_capability as refused, and its acceptance criteria expected 403 PERMISSION_DENIED for a tenant's data-door create. Stage 8 retires those row objects in the same unreleased v18 ledger (catalog-row-objects-retired), so the data door answers 404 OBJECT_NOT_FOUND to every caller. Both clauses now say the refusal held only while the objects were registered and what answers now; the /meta, clone-door and replacement text are unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../semantic/18.walled-catalog-tenant-writes-retired.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 9452e6b4d95..a9959cf8406 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -16,8 +16,10 @@ export const entry: SemanticMigration = { + 'caller below the platform operator (an ADR-0090 D12 delegate whose scope authors environment ' + 'sets included): PUT, DELETE, publish and rollback of /meta/position, /meta/permission and ' + '/meta/capability on @objectstack/rest, and PUT on the runtime dispatcher; the permission-set ' - + 'clone door (POST /security/permission-sets/:name/clone); and a data-door insert, update or ' - + 'delete on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security', + + 'clone door (POST /security/permission-sets/:name/clone). A data-door insert, update or delete ' + + 'on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security was ' + + 'refused the same way only while those row objects were registered; catalog-row-objects-retired ' + + 'retires them, so the data door now answers 404 OBJECT_NOT_FOUND to every caller', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' @@ -37,7 +39,8 @@ export const entry: SemanticMigration = { + '(403 PERMISSION_DENIED on the runtime dispatcher), the message names the posture and the ' + 'manage_metadata capability, and another organization\'s holder of that set resolves exactly ' + 'what it resolved before. A data-door create on sys_position or sys_permission_set by the same ' - + 'caller answers 403 PERMISSION_DENIED and stores nothing. The platform operator saves the same ' + + 'caller answers 404 OBJECT_NOT_FOUND (the row objects are retired by catalog-row-objects-retired) ' + + 'and stores nothing. The platform operator saves the same ' + 'item with 200, the same organization owner still assigns a catalog permission set to its own ' + 'member with 201, and on a single deployment a manage_metadata holder saves catalog items ' + 'exactly as before. Items saved before this change keep their bytes.', From 5a1e88a2c4bd4e976c29ea3a5577c1cbc54565f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:40:59 +0000 Subject: [PATCH 36/39] docs(changeset): stage 8 names PLATFORM_OBJECT_TENANCY's lost key and the migrate-plan listing; objectql takes minor The published PLATFORM_OBJECT_TENANCY record loses its sys_permission_set key, a narrowing of an exported constant, so objectql is graded minor under the launch-window convention (WHICH LEVEL). On an existing database os migrate plan's unmanaged-table sweep now lists the four retired tables, report-only. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .changeset/15204-s8-catalog-row-objects-retired.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/15204-s8-catalog-row-objects-retired.md b/.changeset/15204-s8-catalog-row-objects-retired.md index 5dfdb6a8447..7ed08713e47 100644 --- a/.changeset/15204-s8-catalog-row-objects-retired.md +++ b/.changeset/15204-s8-catalog-row-objects-retired.md @@ -4,7 +4,7 @@ '@objectstack/spec': minor '@objectstack/core': minor '@objectstack/metadata-protocol': patch -'@objectstack/objectql': patch +'@objectstack/objectql': minor '@objectstack/plugin-sharing': patch '@objectstack/lint': patch '@objectstack/service-settings': patch @@ -37,6 +37,7 @@ What else changes: - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. +- On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. Removed exports, no replacement: @@ -44,3 +45,4 @@ Removed exports, no replacement: - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). +- `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. From 68121ebf4dcdc4666ff04247adb14f1bfc6fda0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:41:00 +0000 Subject: [PATCH 37/39] chore(scripts): the keyed-text-bounds record names the tree its five counts hold on The MEASURED record paired ref eab7236ff7 (a tree that still declares the four catalog row objects) with counts taken on the stage-8 branch, and the header's recipe and its "re-derived at MEASURED.ref ... exactly the record" sentence no longer reproduced it. Re-derived on the current tree: the record is 111/116/236/592/148 on the stage-8 tree (ref 01a3421f42, its base, plus the stage-8 diff); the ref alone reads 115/120/248/612/152. The header now says the ref is the writing PR's base and the census is base plus diff, and gives both readings. Floors and checker behaviour unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- scripts/check-keyed-text-bounds.mjs | 73 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/scripts/check-keyed-text-bounds.mjs b/scripts/check-keyed-text-bounds.mjs index 468579afe8e..e0666d6ba78 100644 --- a/scripts/check-keyed-text-bounds.mjs +++ b/scripts/check-keyed-text-bounds.mjs @@ -78,26 +78,39 @@ * * ## The provenance of a floor, and how to reproduce it * - * `MEASURED` is a claim about ONE named commit -- `MEASURED.ref` -- and never - * about `main`. It is the census the floors under it were derived from, not a - * statement about the tree you are running on. + * `MEASURED` is a claim about ONE named tree, and never about `main`. It is the + * census the floors under it were derived from, not a statement about the tree + * you are running on. * - * ⚠️ `MEASURED.ref` is this gate's PR BASE, and this gate DOES NOT EXIST in - * that tree: the census was taken on the PR branch, and the base is the sha the - * branch had to record. So the obvious recipe -- check the ref out and run the - * gate there -- fails with `MODULE_NOT_FOUND` (measured 2026-08-29), and the - * reader who tries it learns nothing about the record. Reproduce it by running - * TODAY's instrument AGAINST that tree instead. `sweep` is exported and takes a - * root for exactly this: + * ⚠️ `MEASURED.ref` is the BASE of the PR that last wrote the record, and the + * census is that PR's own tree: the base plus the PR's diff, which is the tree + * of the commit that landed the record on `main`. A branch sha would not + * survive a squash landing, so the base is the sha the branch can record. Two + * consequences: * - * git worktree add --detach ../os-keyed-provenance "$REF" # $REF = MEASURED.ref - * node --input-type=module -e "import('./scripts/check-keyed-text-bounds.mjs') - * .then((m) => console.log(m.sweep('../os-keyed-provenance').counts))" + * - Checking the ref out and running the gate there may fail with + * `MODULE_NOT_FOUND` (measured 2026-08-29, when the PR that wrote the first + * record also added this gate). Run TODAY's instrument AGAINST a tree + * instead; `sweep` is exported and takes a root for exactly this: + * + * git worktree add --detach ../os-keyed-provenance "$TREE" + * node --input-type=module -e "import('./scripts/check-keyed-text-bounds.mjs') + * .then((m) => console.log(m.sweep('../os-keyed-provenance').counts))" + * + * - Against the ref ALONE the instrument reads the base's census, which + * differs from the record by exactly what the writing PR changed. The record + * itself reproduces on the landing commit (or on the ref with that PR's diff + * applied). * * All five counts are source-only, so where the tree is available the - * re-derivation is cheap: no install and no build. Re-derived at `MEASURED.ref` - * on 2026-08-29: 113/118/255/594/151 -- exactly the record, which is the - * measurement that rules out "the original count was written wrong". + * re-derivation is cheap: no install and no build. The first record + * (113/118/255/594/151, ref `fa5d137ab0`, written by a PR whose diff declared no + * object) re-derived at its ref on 2026-08-29 -- exactly the record, which is + * the measurement that ruled out "the original count was written wrong". The + * current record is C3 stage 8's: 111/116/236/592/148, re-derived on 2026-10-13 + * on the stage-8 tree (ref `01a3421f42` plus the stage-8 diff). Against the ref + * alone the instrument reads 115/120/248/612/152; the difference is the four + * retired security-catalog object files. * * ⚠️ A SHALLOW clone cannot do this at all -- `MEASURED.ref` predates the * default checkout window, so the commit is simply absent. That is why the @@ -378,21 +391,23 @@ const ALLOWLIST_KINDS = new Set(['unboundable', 'pending']); // make visible in a diff. const MEASURED = Object.freeze({ - // The commit this census describes. ⚠️ It is this gate's PR BASE, not a tree - // the gate ever ran in -- see the header for the recipe that actually - // reproduces it, and for why that distinction is not a detail. Immutable, so - // the record stays reproducible forever even as `main` moves away from it. - // ⛔ Never repoint it without re-running all five counts against the new ref. + // The base of the PR that last wrote this census; the census is that base + // plus the PR's diff. ⚠️ Not a tree the five counts hold on by itself -- see + // the header for the recipe that actually reproduces it, and for why that + // distinction is not a detail. Immutable, so the record stays reproducible + // forever even as `main` moves away from it. + // ⛔ Never repoint it without re-running all five counts against the new tree. // // [C3 stage 8] Re-measured, all five, when the four security-catalog row - // objects retired (ADR-0131 D3) and took their declarations and 25 index - // entries with them: the census is the stage-8 branch's, the ref its base. - ref: 'eab7236ff7', - files: 108, - objects: 113, - indexEntries: 230, - textFields: 569, - keyedTextColumns: 144, + // objects retired (ADR-0131 D3) and took 4 files, 4 objects, 12 index entries, + // 20 text fields and 4 keyed columns with them: the census is the stage-8 + // tree's, the ref its base. + ref: '01a3421f42', + files: 111, + objects: 116, + indexEntries: 236, + textFields: 592, + keyedTextColumns: 148, }); const MIN_FILES = 105; const MIN_OBJECTS = 110; From 17ab8fc1108f0b232a71b04e3e0cb62b9d9b2e75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:32:53 +0000 Subject: [PATCH 38/39] chore(regen): regenerate the tenant-audit census page and counts ledger on the merged tree node scripts/tenant-audit-census.mjs --write over main 6d7f4df3c9 plus stage 8: main's two moved counts and stage 8's retired objects together. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- content/docs/permissions/tenant-audit-census.mdx | 12 ++++++------ ...2026-08-tenant-audit-write-call-sites.counts.md | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 7546a0b13d9..a8be2d22d5c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -226,7 +226,7 @@ cannot read, and they are neither in nor out. | write call sites on the application surface | **228** | | …whose object name is statically decidable | 155 | | …whose object name is chosen at run time | 73 | -| …against an object with tenancy ENABLED | 154 | +| …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 167 | | PROVABLY carrying none (options read, no context key) | **8** | @@ -240,9 +240,9 @@ cannot read, and they are neither in nor out. | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 27 | | receiver erased, placed by an `object: string` PARAMETER | 7 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 8 | | object name spelled inline | 103 | | object name spelled through a `const` | 52 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `ae14c39c5`. +Measured on 2026-10-13 at `7c01d30c9`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 654 | +| tracked non-test sources scanned | 650 | | engine-shaped types recognised | 73 | -| declared objects in the registry | 120 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d7a1fe0e1a3..553ee5a9e8a 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -36,7 +36,7 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Write call sites | 228 | | Object name statically decidable | 155 | | Object name chosen at run time | 73 | -| Against a tenancy-enabled object | 154 | +| Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 167 | | Provably carrying none | 8 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-13 at `ae14c39c5`. +Measured on 2026-10-13 at `7c01d30c9`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 654 | +| tracked non-test sources scanned | 650 | | engine-shaped types recognised | 73 | -| declared objects in the registry | 120 | +| declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | ## Every site @@ -163,10 +163,10 @@ Measured on 2026-10-13 at `ae14c39c5`. | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/position-environment-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | enabled | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `delete` | `sys_position` | undeclared-name | elevated | 1 | +| `packages/plugins/plugin-security/src/position-write-through.ts` | `update` | `sys_position` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | From 23e450a684f778f8eb84a575049aeb6cc21832e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:34:30 +0000 Subject: [PATCH 39/39] chore(scripts): the keyed-text-bounds record's ref is the final merge base, and its delta is itemised MEASURED.ref moves to 6d7f4df3c9, the base this branch now merges; the five counts are unchanged there (111/116/236/592/148 on base plus the stage-8 diff; 115/120/248/612/152 on the base alone). The header itemises the net delta: the four retired object files carry 21 text fields and 5 keyed columns, and permission_set_id's move to Field.text adds one of each back. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- scripts/check-keyed-text-bounds.mjs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/check-keyed-text-bounds.mjs b/scripts/check-keyed-text-bounds.mjs index e0666d6ba78..862a9e2b540 100644 --- a/scripts/check-keyed-text-bounds.mjs +++ b/scripts/check-keyed-text-bounds.mjs @@ -108,9 +108,12 @@ * object) re-derived at its ref on 2026-08-29 -- exactly the record, which is * the measurement that ruled out "the original count was written wrong". The * current record is C3 stage 8's: 111/116/236/592/148, re-derived on 2026-10-13 - * on the stage-8 tree (ref `01a3421f42` plus the stage-8 diff). Against the ref - * alone the instrument reads 115/120/248/612/152; the difference is the four - * retired security-catalog object files. + * on the stage-8 tree (ref `6d7f4df3c9` plus the stage-8 diff). Against the ref + * alone the instrument reads 115/120/248/612/152. The difference is the four + * retired security-catalog object files (-4 files, -4 objects, -12 index + * entries, -21 text fields, -5 keyed columns), less one text field the same diff + * adds: `sys_user_permission_set.permission_set_id` became `Field.text` and is + * keyed by its unique index (+1 text field, +1 keyed column). * * ⚠️ A SHALLOW clone cannot do this at all -- `MEASURED.ref` predates the * default checkout window, so the commit is simply absent. That is why the @@ -399,10 +402,10 @@ const MEASURED = Object.freeze({ // ⛔ Never repoint it without re-running all five counts against the new tree. // // [C3 stage 8] Re-measured, all five, when the four security-catalog row - // objects retired (ADR-0131 D3) and took 4 files, 4 objects, 12 index entries, - // 20 text fields and 4 keyed columns with them: the census is the stage-8 - // tree's, the ref its base. - ref: '01a3421f42', + // objects retired (ADR-0131 D3): net 4 files, 4 objects, 12 index entries, 20 + // text fields and 4 keyed columns fewer (the header itemises it). The census is + // the stage-8 tree's, the ref its base. + ref: '6d7f4df3c9', files: 111, objects: 116, indexEntries: 236,