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 e68d60346aa..3f38f799385 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); @@ -838,9 +839,10 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { const maps = RECORD_PAGES.flatMap((page) => inlineLocaleMaps(page, page.name)); // Same refusal as the population floor: zero maps means the walk broke, not - // that the pages went monolingual. [#15204 B2] 44: the user page's two - // grant lists dropped their `add` pickers, and with them two label maps. - expect(maps.length).toBeGreaterThanOrEqual(44); + // 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(38); const incomplete = maps .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) @@ -1041,14 +1043,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', ]), ); @@ -1063,7 +1065,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, @@ -1202,10 +1204,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 9d424a42258..986ebd70c2e 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..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,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, 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/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index da4d955dd9f..f2b6445ee31 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/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts new file mode 100644 index 00000000000..064b9742115 --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts @@ -0,0 +1,70 @@ +// 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 unknown[]): unknown[] { + const declared = objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === SysPermissionSet.name); + return declared ? [...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 7d43ddfc464..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); 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 bfcac098284..ae1f9191b88 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 @@ -51,7 +51,7 @@ 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 { 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 095c5a6c164..224650c766f 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 @@ -71,7 +71,7 @@ import { 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 { 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 6e35b982e44..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#22169] `bootstrapPlatformAdmin`'s one catalog read: a read that did not - * answer is not an absent row. - * - * [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 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'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; - -afterEach(() => { vi.restoreAllMocks(); }); - -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: Array<{ object: string; data: 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) { - inserted.push({ object, data: { ...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 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')], { logger }); - - 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 "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.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/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts index d74321a5e4b..08ab87b6d27 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -183,8 +183,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 bcd6544881b..454884d61ad 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -66,6 +66,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; @@ -224,7 +225,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) { @@ -401,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, 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[]; @@ -410,13 +412,18 @@ 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, + // 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. @@ -527,7 +534,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, judgesCreate(scenario))) + .toEqual(refusalGoldenFor(scenario)); }); } }); @@ -539,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 refused as well - * ({@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': { @@ -571,17 +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 - * 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 + * 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, ...withoutCreate } = REFUSAL_GOLDEN; const golden: Record = scenario.posture === 'single' ? REFUSAL_GOLDEN - : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; + : 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/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 70b8d20d101..3895d8f8053 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -5,6 +5,21 @@ import { describe, it, expect } from 'vitest'; import { cleanupPackagePermissions } from './cleanup-package-permissions'; +/** + * 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 DECLARED = { permission: ['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 +32,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 +62,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 +113,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 +122,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 +131,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 +150,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 +159,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); }); }); }); @@ -166,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']), + ); }); }); 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 c58beb5d571..32b750ed26a 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 @@ -39,9 +39,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 261c184dbd2..bd5802bec94 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 @@ -36,10 +36,10 @@ 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 { 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 { @@ -674,13 +674,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 6725e6322be..6d42aa4cf6f 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 @@ -46,10 +46,10 @@ 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 { 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 7262d5eb04b..3c01a5db8f1 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 e28bfe95905..ff7a37634b0 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 @@ -46,10 +46,10 @@ import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-p import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.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 279f289f43b..22ec4152182 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 @@ -38,10 +38,10 @@ import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-p import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.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 c84d8a3c06d..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,116 +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('[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 @@ -319,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 10b5df747dd..dd1c82871fd 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -29,9 +29,9 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveUserAuthzGrants } from '@objectstack/core'; -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 bbea2fce923..00000000000 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ /dev/null @@ -1,901 +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', () => { - // [ADR-0131 D3] Under a wall the catalog is the platform operator's: an - // organization administrator's Setup create is refused at the data door - // (`walled-catalog-data-door.ts`) before any row or definition exists. It - // used to land a row of the organization with no definition; the refusal - // and its assignment control are pinned in `walled-catalog-data-door.test.ts`. - it('a walled posture: an organization administrator\'s create is refused 403, with no row and no definition', async () => { - const b = await boot({ posture: 'isolated' }); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - expect(envelope(await refusalOf(create(b, { name: 'plant_lead', label: 'Plant lead' })))) - .toEqual({ code: 'PERMISSION_DENIED', status: 403 }); - expect(await b.rows('plant_lead')).toEqual([]); - 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. - // - // The row-only position is the population a wall's Setup created before the - // walled catalog refusal (#15196 Q3 = A), so it is seeded here as that - // existing row, a system write into the organization: a tenant can no longer - // create one. - 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 b.engine.insert('sys_position', { name: 'plant_auditor', label: 'Plant auditor' }, - { context: { isSystem: true, tenantId: ORG } } as any); - 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 813dc8929e9..a271b4523af 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -50,9 +50,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 8a0e1c68b0f..71cf6dd152c 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 @@ -40,10 +40,10 @@ 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 { 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 dc375fc6daf..a00e81742d6 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 @@ -33,10 +33,10 @@ 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 { 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 88bdaa74d05..bb7cbee56a8 100644 --- a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts @@ -278,7 +278,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 7421d33b481..fef26209f32 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 @@ -195,7 +195,6 @@ 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 catalogPermissionSet(ql, unmaskSet.name); diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index 36aade5c521..fb1d25b12c6 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -252,8 +252,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/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts index fd48332bbe4..cce8db7595a 100644 --- a/packages/qa/dogfood/test/catalog-permission-set.ts +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -23,42 +23,3 @@ export async function catalogPermissionSet(ql: unknown, name: string): 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/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/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/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 fd675b10c3c..00000000000 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ /dev/null @@ -1,274 +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'; -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)); -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. [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(); - 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 10b54cd131f..00000000000 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ /dev/null @@ -1,178 +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'; -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)); -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'); - // [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', { - 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 a3b0a6f97b8..e353973f854 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 @@ -138,13 +138,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); 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 821d7b294d2..d9a4f06e666 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 @@ -621,11 +621,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 e0a67c590c4..3f5dce51b47 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 ──────────────────── // @@ -117,25 +117,22 @@ 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); + expect(authored.status, 'scratch set authored through the metadata door').toBeLessThan(300); 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; 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 c493df2f310..00000000000 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ /dev/null @@ -1,199 +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 `allowEnvironmentOverlay: 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'; -import { ensureLegacyPermissionSetRow } from './catalog-permission-set.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'); - // [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(); }); - - 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 `allowEnvironmentOverlay` (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 `allowEnvironmentOverlay: 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-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 0df4427537c..b03ac9a7908 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -44,10 +44,14 @@ describe('showcase: declared permission sets are served by the security catalog 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 }); + // The census runs where the row object is still registered. (C3 stage 8 + // retires the object, and there is then no table to hold a row at all.) + if (ql.getObject?.('sys_permission_set')) { + 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 69a5627a97e..33910b48b70 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -13,7 +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'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -132,36 +132,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 }); - }; - // [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 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, - 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', @@ -194,38 +168,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 existingSetRow('showcase_manager'); - 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 existingSetRow('showcase_auditor'); - 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 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, - }); - 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 1ab0dbbf0c8..00000000000 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ /dev/null @@ -1,160 +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 `allowEnvironmentOverlay: 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'; -import { ensureLegacyPermissionSetRow } from './catalog-permission-set.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'); - 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]; - - // [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 () => { - // Before commit ee58392e1 this edit became an env-scope overlay (ADR-0094's 2026-07-14 - // direction). `permission` has since rolled back to - // `allowEnvironmentOverlay: 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 `allowEnvironmentOverlay: 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/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 () => { 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 d50d18afb94..220548ecb2c 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -216,6 +216,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 { @@ -283,7 +284,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); 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');