From 813dfed93e74e6bb2ce7b31ce15e9d23f853f55d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:30:00 +0000 Subject: [PATCH 1/8] =?UTF-8?q?test:=20the=20stage-8=20test=20half=20?= =?UTF-8?q?=E2=80=94=20suites=20stop=20depending=20on=20the=20four=20catal?= =?UTF-8?q?og=20row=20objects=20(#15204=20stage=208,=20S8-T)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every touched suite stays green on main and on main + D + S8, where sys_position, sys_permission_set, sys_position_permission_set and sys_capability are no longer registered (ADR-0131 D3). - a testkit holds the retired tables' shapes for suites that model an existing database; - pins of the retired declarations, the unwired data-door write-throughs, the projection, the row lock and the junction gates are deleted; - fixtures author permission sets through the metadata door; - prose and floor pins span the route move and the retired page; - new: a fresh-boot promotion golden (E6) and a metadata-door-reaches- the-catalog dogfood test (the premise of E1). Claude-Session: https://claude.ai/code/session_01MGoWgeE8rhsrig77V4oUZn Co-authored-by: Claude --- .../organization-ownership-inventory.test.ts | 3 +- .../test/platform-page-i18n-parity.test.ts | 19 +- packages/core/src/security/row-active.test.ts | 48 - .../validate-capability-references.test.ts | 22 +- .../protocol.packaged-base-refusal.test.ts | 10 +- ...protocol.uninstall-cleanups-runner.test.ts | 4 +- ...ata-repository.package-writability.test.ts | 34 +- .../dangling-reference-audit.test.ts | 74 +- .../tenancy-by-object-classification.test.ts | 11 +- .../canonical-expression-envelopes.test.ts | 5 +- .../src/grant-readers-by-name.golden.test.ts | 4 +- .../src/grant-readers-unnamed-grant.test.ts | 8 +- .../legacy-catalog-objects.testkit.ts | 521 ++++++++++ ...latform-admin-authenticable-target.test.ts | 2 +- ...latform-admin-existing-holder-scan.test.ts | 2 +- ...p-platform-admin-fresh-boot.golden.test.ts | 107 +++ ...platform-admin-promotion-selection.test.ts | 2 +- ...tform-admin-refused-existence-read.test.ts | 106 --- .../src/builtin-capabilities.boot.test.ts | 7 +- .../src/builtin-positions.boot.test.ts | 4 +- .../src/catalog-reference-report.test.ts | 41 +- .../claim-seed-ownership-warm-boot.test.ts | 1 - .../src/cleanup-package-permissions.test.ts | 84 +- .../src/engine-find-bare-array.pin.test.ts | 6 +- .../src/explain-asker-gate.test.ts | 6 +- .../src/explain-enforce-parity.test.ts | 6 +- .../explain-removed-member-principal.test.ts | 6 +- .../get-effective-object-permissions.test.ts | 14 +- .../grant-holder-membership-refusal.test.ts | 6 +- ...grant-permission-set-name-backfill.test.ts | 17 +- ...nt-permission-set-name.equivalence.test.ts | 6 +- .../src/grant-permission-set-name.test.ts | 6 +- .../grant-permission-set-name.writers.test.ts | 2 +- .../src/grant-readers-by-name.golden.test.ts | 6 +- .../src/grant-readers-unnamed-grant.test.ts | 6 +- .../src/granted-by-writer-provenance.test.ts | 9 +- .../identifier-storage-ceiling-pin.test.ts | 41 +- .../src/objects/clone-carryover.test.ts | 65 -- .../objects/managed-by-system-data.test.ts | 2 - .../src/objects/rbac-objects.test.ts | 159 +--- .../objects/reserved-identity-names.test.ts | 99 +- .../sys-capability-deactivation-claim.test.ts | 174 ---- ...sys-capability.organization-unique.test.ts | 64 -- ...permission-set.organization-unique.test.ts | 98 -- .../sys-position.organization-unique.test.ts | 130 --- ...-security-assignment-display-title.test.ts | 6 - .../src/orgless-position-name-fold.test.ts | 6 +- .../src/packaged-permission-set-lock.test.ts | 253 +---- .../src/per-organization-catalog.test.ts | 6 +- ...rmission-set-projection-heal-by-id.test.ts | 2 +- .../src/plugin-keyed-text-bounds.test.ts | 20 +- .../src/position-catalog-refusal.test.ts | 6 +- .../src/position-write-through.test.ts | 889 ------------------ .../src/private-credential-row-scope.test.ts | 6 +- .../src/rbac-objects-bulk-exposure.test.ts | 8 - .../src/read-fault-distinguishability.test.ts | 6 +- ...lve-authz-grant-set-by-name.golden.test.ts | 6 +- .../resolve-authz-grant-set-by-name.test.ts | 6 +- ...riable-root-comparand-fails-closed.test.ts | 6 +- .../src/scim-projection-row-scope.test.ts | 6 +- ...s-user-permission-set-window-order.test.ts | 6 +- .../src/tenant-wall-post-hook-image.test.ts | 75 +- .../src/translations/bundle-ownership.test.ts | 24 +- .../position-rename-consistency.test.ts | 9 +- .../activity-text-predicate.dogfood.test.ts | 4 +- ...-snapshot-credential-field.dogfood.test.ts | 1 - ...oval-snapshot-masked-field.dogfood.test.ts | 6 +- .../qa/dogfood/test/armed.dogfood.test.ts | 4 +- ...clared-position-provenance.dogfood.test.ts | 272 ------ .../lookup-title-exposure.dogfood.test.ts | 1 - ...apps-and-everyone-baseline.dogfood.test.ts | 32 +- ...adata-door-reaches-catalog.dogfood.test.ts | 90 ++ .../permission-set-clone-door.dogfood.test.ts | 15 +- ...on-set-lock-row-provenance.dogfood.test.ts | 269 ------ ...te-through-package-binding.dogfood.test.ts | 174 ---- ...-environment-write-through.dogfood.test.ts | 155 --- ...ion-held-name-setup-create.dogfood.test.ts | 104 -- ...aring-rule-org-less-caller.dogfood.test.ts | 18 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 12 +- ...howcase-d7-default-profile.dogfood.test.ts | 4 +- ...owcase-fls-read-mask-strip.dogfood.test.ts | 27 +- ...case-permission-projection.dogfood.test.ts | 193 ---- .../showcase-permission-zoo.dogfood.test.ts | 63 +- .../test/two-doors-permission.dogfood.test.ts | 186 ---- packages/qa/dogfood/vitest.config.ts | 1 - ...packaged-action-permission-refusal.test.ts | 4 +- ...lone-stack-seeder-declaration-copy.test.ts | 35 +- .../src/system/constants/system-names.test.ts | 5 - 88 files changed, 1122 insertions(+), 3946 deletions(-) delete mode 100644 packages/core/src/security/row-active.test.ts create mode 100644 packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/clone-carryover.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts delete mode 100644 packages/plugins/plugin-security/src/position-write-through.test.ts delete mode 100644 packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts delete mode 100644 packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index 9009efdc7c8..41955902457 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -86,7 +86,8 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci it('the platform registry as it stands — a platform object without a row reds here, by name', () => { const registered = Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat(); - expect(registered.length).toBeGreaterThan(80); + // (The four retired catalog row objects left the registry with C3 stage 8.) + expect(registered.length).toBeGreaterThan(75); expect(unlisted(registered)).toEqual([]); }); diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 5afc100a2e1..6e72ab476d9 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -788,9 +788,10 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // red here. What this refuses is the scan that finds NOTHING -- an empty // population would satisfy every `for` loop below and report success over // zero pages, which is the exact shape of the `0` that reads as "clean". - expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(3); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(2); expect(RECORD_PAGES.map((p) => p.name).sort()).toEqual( - expect.arrayContaining(['sys_organization_detail', 'sys_position_detail', 'sys_user_detail']), + expect.arrayContaining(['sys_organization_detail', 'sys_user_detail']), ); expect(SHIPPED_LOCALES).toContain(EN); expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); @@ -839,7 +840,8 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // Same refusal as the population floor: zero maps means the walk broke, not // that the pages went monolingual. - expect(maps.length).toBeGreaterThanOrEqual(45); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(maps.length).toBeGreaterThanOrEqual(40); const incomplete = maps .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) @@ -1040,14 +1042,14 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 // The anti-vacuity floor, stated before the verdict runs. A verdict over an // empty or shrunken population is the ambiguous `0` this whole card is // about: every loop below would be satisfied and report success. - expect(SOURCE_BY_NAME.size).toBeGreaterThanOrEqual(6); + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + expect(SOURCE_BY_NAME.size).toBeGreaterThanOrEqual(5); expect([...SOURCE_BY_NAME.keys()].sort()).toEqual( expect.arrayContaining([ 'cloud_connection_settings', 'connect_agent', 'marketplace_installed', 'sys_organization_detail', - 'sys_position_detail', 'sys_user_detail', ]), ); @@ -1062,7 +1064,7 @@ describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #1 const enPages = pagesOf(EN); // Absence is loud: an `en` section that lost its entries would make the // verdict below compare nothing at all. - expect(Object.keys(enPages).length).toBeGreaterThanOrEqual(6); + expect(Object.keys(enPages).length).toBeGreaterThanOrEqual(5); const findings = defaultLocalePageDrift(enPages, SOURCE_BY_NAME).map((f) => ({ ...f, @@ -1201,10 +1203,11 @@ describe('platform record page labels are translated in every shipped locale (#1 * wrong recorded source hash would serve the English source here and red this * assertion rather than shipping a silent regression. */ - const RECORD_PAGE_NAMES = ['sys_user_detail', 'sys_organization_detail', 'sys_position_detail']; + // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) + const RECORD_PAGE_NAMES = ['sys_user_detail', 'sys_organization_detail']; const TRANSLATED_LOCALES = SHIPPED_LOCALES.filter((locale) => locale !== EN); - it('has a real, non-English label for each of the three in each translated locale', () => { + it('has a real, non-English label for each record page in each translated locale', () => { expect(TRANSLATED_LOCALES.length).toBeGreaterThanOrEqual(3); const english = pagesOf(EN); diff --git a/packages/core/src/security/row-active.test.ts b/packages/core/src/security/row-active.test.ts deleted file mode 100644 index 83118f95b34..00000000000 --- a/packages/core/src/security/row-active.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { isRowActive } from './row-active.js'; - -/** - * [#8613 / ADR-0049] The ONE predicate three readers share — the core resolver - * that enforces `active`, the plugin-security loader that judges the same flag - * on the name-reuse path, and the break-glass guard that SIMULATES a write to - * it. The reason it is one function is that a guard modelling "deactivated" - * differently from the resolver would permit exactly the write it exists to - * refuse. - * - * Both directions are pinned deliberately, because both have a real failure - * mode: reading absent as inactive mass-revokes deployed rows, and reading only - * `=== false` as inactive misses the shape SQLite actually stores. - */ -describe('[#8613] isRowActive — explicitly deactivated, not explicitly active', () => { - it('treats the stored spellings of OFF as deactivated', () => { - for (const off of [false, 0, '0', 'false']) { - expect(isRowActive({ active: off })).toBe(false); - } - }); - - it('treats the stored spellings of ON as active', () => { - for (const on of [true, 1, '1', 'true']) { - expect(isRowActive({ active: on })).toBe(true); - } - }); - - it('ABSENT means active — a row predating the column keeps granting', () => { - expect(isRowActive({})).toBe(true); - expect(isRowActive({ active: undefined })).toBe(true); - expect(isRowActive({ active: null })).toBe(true); - }); - - it('an unrecognised value is NOT a deactivation — junk may not revoke', () => { - // Nothing writes these. Inventing a revocation out of unreadable data is - // the direction that ends in a lockout nobody ordered. - expect(isRowActive({ active: 'yes' } as never)).toBe(true); - expect(isRowActive({ active: {} } as never)).toBe(true); - }); - - it('a missing row is not active — a filter over nothing grants nothing', () => { - expect(isRowActive(undefined)).toBe(false); - expect(isRowActive(null)).toBe(false); - }); -}); diff --git a/packages/lint/src/validate-capability-references.test.ts b/packages/lint/src/validate-capability-references.test.ts index e9865687e0d..9800b62959b 100644 --- a/packages/lint/src/validate-capability-references.test.ts +++ b/packages/lint/src/validate-capability-references.test.ts @@ -44,14 +44,6 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => { expect(findings).toEqual([]); }); - it('passes a reference to a capability shipped as a sys_capability seed row', () => { - const findings = validateCapabilityReferences({ - data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }], - objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }], - }); - expect(findings).toEqual([]); - }); - it('warns on an object requiredPermissions typo (registered nowhere)', () => { const findings = validateCapabilityReferences({ objects: [{ name: 'sys_license', requiredPermissions: ['mange_users'] }], @@ -193,10 +185,11 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { const findings = validateCapabilityReferences({ objects: [{ name: 'sys_license', requiredPermissions: ['mange_users'] }], }); - expect(findings.map((f) => f.message)).toEqual([ - 'requiredPermissions names capability "mange_users", which no built-in, `defineCapability`, ' - + '`systemPermissions` grant or `sys_capability` seed provides, so this gate fails closed', - ]); + expect(findings).toHaveLength(1); + const [message] = findings.map((f) => f.message); + expect(message.startsWith('requiredPermissions names capability "mange_users", which no built-in, `defineCapability`')).toBe(true); + expect(message.endsWith('so this gate fails closed')).toBe(true); + expect(message.split('\n')).toHaveLength(1); }); it('`os explain` carries what the verdict no longer says', () => { @@ -218,10 +211,10 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { }); it('each source of the known set the explanation names silences the rule', () => { - // The explanation module imports nothing, so its four sources are held to + // The explanation module imports nothing, so its sources are held to // the rule by running the rule: each one makes the same reference known. const text = explainRule(CAPABILITY_REFERENCE_UNKNOWN)!.paragraphs.join('\n'); - for (const named of ['built-in platform capability', '`defineCapability`', '`systemPermissions`', '`sys_capability` seed row']) { + for (const named of ['built-in platform capability', '`defineCapability`', '`systemPermissions`']) { expect(text, named).toContain(named); } const gated = { objects: [{ name: 'inv_invoice', requiredPermissions: ['approve_invoice'] }] }; @@ -229,7 +222,6 @@ describe('[#22161] one-line verdicts — capability-reference-unknown', () => { for (const source of [ { capabilities: [{ name: 'approve_invoice', label: 'Approve', scope: 'org' }] }, { permissions: [{ name: 'p', systemPermissions: ['approve_invoice'] }] }, - { data: [{ object: 'sys_capability', records: [{ name: 'approve_invoice' }] }] }, ]) { expect(validateCapabilityReferencesUnrecorded({ ...gated, ...source }), JSON.stringify(source)).toEqual([]); } diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index 1bba1f2c3f9..448425c6a34 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -369,10 +369,10 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa it(`\`permission\` — save names its clone and no switch (${environmentId ? 'environment' : 'host-config'} kernel)`, () => { const message = expectRegimeC(refusalOn(environmentId, 'permission', 'pkg_perm', 'save'), 'permission', 'pkg_perm', 'save'); - expect(message).toContain( - 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).', - ); + // The clone route's spelling moved with C3 stage 8 (the retired + // object's data route → the catalog's clone door); the sentence did not. + expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); + expect(message).toContain(' with a new name).'); expect(message.toLowerCase()).not.toContain('switch it off'); expect(message).not.toContain('_activation'); expect(message).not.toContain('/automation/'); @@ -411,7 +411,7 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa for (const [type, name, path] of [ ['flow', 'pkg_flow', 'POST /api/v1/automation/:name/clone'], ['action', 'pkg_action', 'POST /api/v1/actions/_activation/:object/:action'], - ['permission', 'pkg_perm', 'POST /api/v1/data/sys_permission_set'], + ['permission', 'pkg_perm', 'the "Clone" action on the permission set, or POST /api/v1/'], ] as const) { const r: any = p.packagedBaseRefusal({ type, name, operation: 'save', packageId: PACKAGE_ID }); expect({ code: r?.code, status: r?.status, lockSource: r?.lockSource, packageId: r?.packageId }, type) diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index 02350f17f4c..409da59850b 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,7 +99,9 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - expect(runner.mock.calls).toEqual([[request]]); + // Its own request — plus, since C3 stage 8, the catalog items the package + // declared (`declared`), read before its withdrawal. + expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index 4c985c5cc9b..89fdb8967fc 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -574,10 +574,11 @@ describe('#7682 — the refusal discriminates on package writability', () => { `Cannot overlay 'permission' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + 'is locked against in-place edits. ', )).toBe(true); - expect(message).toContain( - 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).', - ); + // The clone route's spelling moved with C3 stage 8 (the data route of the + // retired `sys_permission_set` object → the catalog's clone door); the + // sentence around it did not. + expect(message).toContain('Clone it under a new name to customize it (the "Clone" action on the permission set, or POST /api/v1/'); + expect(message).toContain(' with a new name).'); expect(message).not.toContain('OS_METADATA_WRITABLE'); expect(message).not.toContain('redeploy'); expect(message.endsWith('See docs/adr/0126-packaged-metadata-customization-model.md.')).toBe(true); @@ -1253,8 +1254,17 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const ADR_0126 = 'See docs/adr/0126-packaged-metadata-customization-model.md.'; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, ' + ':object = global for an object-less action; operator-only where one install serves several organizations).'; + // `{ROUTE}`: the clone route, whose spelling moved with C3 stage 8 (the + // retired object's data route → the catalog's clone door); see `sentenceMatches`. const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).'; + + 'or POST {ROUTE} with a new name).'; + /** Exact equality, with a `{ROUTE}` placeholder standing for one `/api/v1/…` path. */ + const sentenceMatches = (actual: string, expected: string): boolean => { + const [head, tail] = expected.split('{ROUTE}'); + if (tail === undefined) return actual === expected; + return actual.startsWith(head) && actual.endsWith(tail) + && /^\/api\/v1\/\S+$/.test(actual.slice(head.length, actual.length - tail.length)); + }; const FLOW_PATHS = 'Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), ' + 'or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations).'; @@ -1286,10 +1296,9 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, it(`type door, save: a packaged ${type} is refused NOT_OVERRIDABLE with its row's path, no hatch`, async () => { const err = await putWith(repo, { type, name: `pkg_${type}`, intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(String(err.message)).toBe( - `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` - + `against in-place edits. ${paths} ${ADR_0126}`, - ); + const expected = `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + + `against in-place edits. ${paths} ${ADR_0126}`; + expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); expect(Array.from(engine.rows.values())).toEqual([]); }); } @@ -1359,10 +1368,9 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG, docs: 'docs/adr/0010-metadata-protection-model.md', }); - expect(String(err.message)).toBe( - `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` - + `is locked against in-place edits. ${paths} ${ADR_0126}`, - ); + const expected = `Cannot overlay '${type}' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + + `is locked against in-place edits. ${paths} ${ADR_0126}`; + expect(sentenceMatches(String(err.message), expected), `${String(err.message)}\n≠ ${expected}`).toBe(true); }); } diff --git a/packages/objectql/src/integrity/dangling-reference-audit.test.ts b/packages/objectql/src/integrity/dangling-reference-audit.test.ts index 92911b1033e..356e409bfaa 100644 --- a/packages/objectql/src/integrity/dangling-reference-audit.test.ts +++ b/packages/objectql/src/integrity/dangling-reference-audit.test.ts @@ -32,9 +32,13 @@ import { type DanglingReferenceAuditPort, } from './dangling-reference-audit.js'; -/** The RBAC link-table shape #4551 calls out as the priority case. */ +/** + * The RBAC link-table shape #4551 calls out as the priority case. Named for a + * security table that survives the catalog row objects' retirement (C3 stage 8: + * `sys_position_permission_set` is gone); the shape is the fixture's own. + */ const binding: AuditableObject = { - name: 'sys_position_permission_set', + name: 'sys_user_permission_set', fields: { id: { type: 'text', primaryKey: true }, position_id: { type: 'lookup', reference: 'sys_position' }, @@ -123,7 +127,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_real', permission_set_id: 'ps_does_not_exist_at_all' }, ], }, @@ -138,7 +142,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () // Which object, which record, which field, which id, which target object — // the report is addressed to a human who has to go fix it. expect(out.dangling[0]).toEqual({ - objectName: 'sys_position_permission_set', + objectName: 'sys_user_permission_set', recordId: 'ppr_1', field: 'permission_set_id', target: 'sys_permission_set', @@ -150,7 +154,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_real', permission_set_id: 'ps_real' }, ], }, @@ -170,7 +174,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_1', permission_set_id: 'ps_1' }, ], }, @@ -189,7 +193,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_1', permission_set_id: 'ps_1' }, ], }, @@ -205,12 +209,12 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding, task], rows: { showcase_task: [{ id: 't1', title: 'T', project: 'proj_real' }] }, - unreadable: new Set(['sys_position_permission_set']), + unreadable: new Set(['sys_user_permission_set']), existing: new Set(['showcase_project proj_real']), }); const out = await auditDanglingReferences(port); - expect(out.unreadableObjects).toEqual(['sys_position_permission_set']); + expect(out.unreadableObjects).toEqual(['sys_user_permission_set']); expect(out.dangling).toEqual([]); }); @@ -219,7 +223,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () // the stored data disagree with what actually happened, and the remedy // (re-seed the target vs clear the link) is an operator judgement call. const rows: Record>> = { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'ppr_1', position_id: 'pos_gone', permission_set_id: 'ps_gone' }, ], showcase_task: [ @@ -305,17 +309,17 @@ describe('[#4551] dangling stored references are reported, never rewritten', () objects: [task, binding], // registration order puts the business object first rows: { showcase_task: [{ id: 't1', title: 'T', project: 'p' }], - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_1' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_1' }], }, }); const findSpy = port.find.bind(port); port.find = async (o, opts) => { reads.push(o); return findSpy(o, opts); }; await auditDanglingReferences(port); - expect(reads[0]).toBe('sys_position_permission_set'); + expect(reads[0]).toBe('sys_user_permission_set'); // …and the priority set is DERIVED from the platform-object registry, not // hand-listed here, so a new plugin-security table is covered for free. - expect(SECURITY_SURFACE_OBJECTS.has('sys_position_permission_set')).toBe(true); + expect(SECURITY_SURFACE_OBJECTS.has('sys_user_permission_set')).toBe(true); expect(SECURITY_SURFACE_OBJECTS.has('sys_user_permission_set')).toBe(true); }); @@ -344,7 +348,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const port = makePort({ objects: [binding], rows: { - sys_position_permission_set: [ + sys_user_permission_set: [ { id: 'a', permission_set_id: 'ps_gone' }, { id: 'b', permission_set_id: 'ps_gone' }, { id: 'c', permission_set_id: 'ps_gone' }, @@ -362,7 +366,7 @@ describe('[#4551] dangling stored references are reported, never rewritten', () it('the report is logged when there is anything to say, and silent otherwise', async () => { const clean = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); await auditDanglingReferences(clean); @@ -370,13 +374,13 @@ describe('[#4551] dangling stored references are reported, never rewritten', () const dirty = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, }); await auditDanglingReferences(dirty); expect(dirty.warnings).toHaveLength(1); expect(dirty.warnings[0][0]).toContain('reported, never rewritten'); expect((dirty.warnings[0][1] as any).references).toEqual([ - 'sys_position_permission_set#ppr_1.permission_set_id → sys_permission_set#ps_gone', + 'sys_user_permission_set#ppr_1.permission_set_id → sys_permission_set#ps_gone', ]); }); }); @@ -402,7 +406,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const unreadablePort = () => makePort({ objects: [binding], rows: {}, - unreadable: new Set(['sys_position_permission_set']), + unreadable: new Set(['sys_user_permission_set']), }); it('a REAL datasource fault still lands in `unreadableObjects`, loudly', async () => { @@ -413,7 +417,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const out = await auditDanglingReferences(port); - expect(out.unreadableObjects).toEqual(['sys_position_permission_set']); + expect(out.unreadableObjects).toEqual(['sys_user_permission_set']); expect(out.aborted).toBe(false); expect(port.warnings.map((w) => w[0])).toContain( '[integrity] dangling-reference audit could not list an object', @@ -448,7 +452,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const reads: string[] = []; const port = makePort({ objects: [binding, task], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, }); const findSpy = port.find.bind(port); port.find = async (o, opts) => { reads.push(o); return findSpy(o, opts); }; @@ -471,7 +475,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const port = makePort({ objects: [binding, task], // binding is scanned first (security surface) rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], }, unreadable: new Set(['showcase_task']), @@ -487,7 +491,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const out = await auditDanglingReferences(port, { signal }); - expect(reads).toEqual(['sys_position_permission_set', 'showcase_task']); + expect(reads).toEqual(['sys_user_permission_set', 'showcase_task']); expect(out.dangling).toHaveLength(1); expect(out.dangling[0]).toMatchObject({ recordId: 'ppr_1', value: 'ps_gone' }); expect(out.aborted).toBe(true); @@ -507,7 +511,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () const signal = { aborted: false }; const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, throwingTargets: new Set(['sys_permission_set']), }); const probeSpy = port.probe.bind(port); @@ -528,7 +532,7 @@ describe('[#4747] a run that was called off is not a finding about the data', () // has to guess whether `undefined` meant "finished" or "old report shape". const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); @@ -673,7 +677,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => rows: { showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_deleted' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], }, }); const findSpy = port.find.bind(port); @@ -681,7 +685,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => await auditDanglingReferences(port); - expect(reads).toEqual(['sys_position_permission_set', 'showcase_task', 'showcase_note']); + expect(reads).toEqual(['sys_user_permission_set', 'showcase_task', 'showcase_note']); }); it('provenance ALONE does not raise the summary warning', async () => { @@ -731,7 +735,7 @@ describe('[#4743] provenance references are audited, in their OWN bucket', () => // guess whether `undefined` meant "clean" or "old report shape". const port = makePort({ objects: [binding], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }] }, existing: new Set(['sys_permission_set ps_real']), }); @@ -764,7 +768,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const budgetPort = (objects: AuditableObject[]) => makePort({ objects, rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_real' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_real' }], showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_alive' }], }, @@ -785,7 +789,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const out = await auditDanglingReferences(port, { maxRows: 1 }); - expect(reads).toEqual(['sys_position_permission_set']); // budget bought one table + expect(reads).toEqual(['sys_user_permission_set']); // budget bought one table expect(out.scanned).toBe(1); // …and the report SAYS the other two were never opened, instead of leaving // them inside an empty `dangling`. @@ -825,7 +829,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const out = await auditDanglingReferences(port, { maxRows: 1, - objects: ['sys_position_permission_set', 'showcase_task'], + objects: ['sys_user_permission_set', 'showcase_task'], }); // `showcase_note` was never on this run's list, so it is not something the @@ -845,7 +849,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', expect(out.scanned).toBe(0); expect(out.dangling).toEqual([]); // …and this says NOTHING, which is the point expect(out.unscannedObjects).toEqual([ - 'sys_position_permission_set', 'showcase_task', 'showcase_note', + 'sys_user_permission_set', 'showcase_task', 'showcase_note', ]); }); @@ -858,7 +862,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const signal = { aborted: false }; const port = makePort({ objects: [binding, task, note], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }] }, unreadable: new Set(['showcase_task']), }); const findSpy = port.find.bind(port); @@ -884,7 +888,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const signal = { aborted: false }; const port = makePort({ objects: [binding, task, note], - rows: { sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, + rows: { sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_x' }] }, // The probe blows up because the pool went away under it — the #4747 // race, which is what makes the answer "withdrawn" rather than a verdict. throwingTargets: new Set(['sys_permission_set']), @@ -896,7 +900,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', expect(out.aborted).toBe(true); expect(out.unscannedObjects).toEqual(['showcase_task', 'showcase_note']); - expect(out.unscannedObjects).not.toContain('sys_position_permission_set'); + expect(out.unscannedObjects).not.toContain('sys_user_permission_set'); }); it('called off BEFORE the registry was enumerated: `aborted` carries it, the list is empty', async () => { @@ -949,7 +953,7 @@ describe('[#5718] objects a finite budget never reached are named, not dropped', const loud = makePort({ objects: [binding, task, note], rows: { - sys_position_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], + sys_user_permission_set: [{ id: 'ppr_1', permission_set_id: 'ps_gone' }], showcase_task: [{ id: 't1', title: 'T', project: 'proj_gone' }], showcase_note: [{ id: 'n1', body: 'b', created_by: 'usr_deleted' }], }, diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 9049f30940b..ed6d68fef2f 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -134,12 +134,17 @@ describe('#13491 the inventory — a verdict per object, never a namespace', () }); it('#8672 inherits PER OBJECT — the same namespace holds both verdicts', () => { - expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('global'); expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); // The discriminating half: the old blanket predicate answered the SAME - // thing for both of these, which is the exemption the ruling withdrew. - expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); + // thing for every platform object, which is the exemption the ruling + // withdrew. Each verdict the inventory records is honoured as recorded — + // read off the inventory, since its one `global` entry + // (`sys_permission_set`) retired with its object (C3 stage 8). expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); + for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { + expect(classifyPlatformObjectTenancy(name), name).toBe(entry.tenancy); + expect(isPlatformObjectOutOfTenantAuditScope(name), name).toBe(entry.tenancy !== 'tenant-scoped'); + } }); it('the exemption applies to platform namespaces only — an application object was never in it', () => { diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index 6791bc618bd..42b2b6d0480 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -183,8 +183,9 @@ describe('platform Page exports serve canonical expression envelopes', () => { ).toBe(''); // Population floor: the gate is worthless if it silently reads nothing. - expect(EXPORTED_PAGES.length).toBeGreaterThanOrEqual(3); - expect(declared.length).toBeGreaterThanOrEqual(3); + // (The `sys_position` record page retired with its object, C3 stage 8.) + expect(EXPORTED_PAGES.length).toBeGreaterThanOrEqual(2); + expect(declared.length).toBeGreaterThanOrEqual(2); }); it.each(AUDITS)('$exportName parses through PageSchema (door 1 precondition)', ({ audit }) => { diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 93ef93b5a02..c7efb433423 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -155,7 +155,9 @@ async function standingByReader(posture: Posture): Promise { const { engine } = await soleGrantAnchoredAdmin(); const grant = await adminGrant(engine); - const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[]; - await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id, reason: 'renewed' }, SYS); + // The id of another set's legacy catalog row — read off the row where that + // object is still registered, a literal one once it is retired (C3 stage 8). + const viewerId = engine.getObject?.('sys_permission_set') + ? ((await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[])[0]?.id + : 'ps_legacy_viewer_readonly'; + await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewerId, reason: 'renewed' }, SYS); expect(await adminGrant(engine)).toMatchObject({ permission_set: 'admin_full_access', reason: 'renewed' }); }); }); diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts new file mode 100644 index 00000000000..fe0d34d4ce7 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-objects.testkit.ts @@ -0,0 +1,521 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the TABLE shapes of the four retired security-catalog row + * objects — `sys_position`, `sys_capability`, `sys_permission_set` and + * `sys_position_permission_set` (ADR-0131 D3; retired from the plugin by C3 + * stage 8). Fields, indexes and validations only, as the last release that + * declared them stored them. + * + * A suite that models an EXISTING database — rows a release before the + * retirement wrote, which stay in the physical tables until the + * organization-ownership migration (ADR-0131 D10) takes them — registers these + * on its own engine. No runtime code declares them any more, and a suite that + * only boots the platform registers none of them. + */ + +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { P } from '@objectstack/spec'; +import { F } from '@objectstack/spec'; +import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from '../objects/reserved-identity-names.js'; + +const RESERVED_IDENTITY_NAME_CONDITION = + `record.name in ${reservedIdentityNamesCelList()}` + + ` && !(record.managed_by in ['platform', 'system'])`; + +export const SysPosition = ObjectSchema.create({ + name: 'sys_position', + label: 'Position', + pluralLabel: 'Positions', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: + 'Machine name for the position, unique per organization ' + + '(e.g. sales_manager, hr_specialist)', + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + group: 'Status', + }), + + is_default: Field.boolean({ + label: 'Default Position', + defaultValue: false, + description: 'Automatically assigned to new users', + group: 'Status', + }), + + delegatable: Field.boolean({ + label: 'Delegatable', + defaultValue: false, + description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.', + group: 'Status', + }), + + managed_by: Field.select({ + label: 'Managed By', + readonly: true, + defaultValue: 'admin', + description: + 'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' + + 'permission sets and positions all share: platform = framework built-in ' + + '(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' + + 'may carry system (== platform) / config (== package) / user (== admin).', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'System', + }), + + id: Field.text({ + label: 'Position ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, + + validations: [ + { + type: 'cross_field', + name: 'reserved_identity_name', + label: 'Reserved built-in identity name', + description: + 'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own ' + + 'catalog seed may spell one; a tenant- or package-authored position must choose another name.', + fields: ['name', 'managed_by'], + condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION }, + severity: 'error', + message: reservedIdentityNameMessage('name'), + }, + ], +}); + +export const SysCapability = ObjectSchema.create({ + name: 'sys_capability', + label: 'Capability', + pluralLabel: 'Capabilities', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: 'Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access).', + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + scope: Field.select({ + label: 'Scope', + required: true, + defaultValue: 'platform', + description: 'platform = a platform-wide power; org = scoped to an organization.', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'org', label: 'Organization' }, + ], + group: 'Classification', + }), + + managed_by: Field.select({ + label: 'Managed By', + required: true, + defaultValue: 'admin', + description: 'platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'Classification', + }), + + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: 'Package that ships this capability (absent = platform-curated or admin-created).', + group: 'Classification', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + description: + 'Catalogue/visibility flag for filtering and review. It has NO authorization effect: permission-set grants and resource requiredPermissions match capability names as strings and never read this row, so clearing it revokes nothing.', + group: 'Status', + }), + + id: Field.text({ + label: 'Capability ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['scope'] }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +export const SysPermissionSet = ObjectSchema.create({ + name: 'sys_permission_set', + label: 'Permission Set', + pluralLabel: 'Permission Sets', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ + label: 'Display Name', + required: true, + searchable: true, + maxLength: 255, + group: 'Identity', + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: + 'Machine name for the permission set, unique per organization. This is the set’s ' + + 'metadata identity (ADR-0094) and cannot be changed after creation — the data door ' + + 'rejects a rename; clone the set to a new name instead.', + readonlyWhen: P`record.id != null && record.id != ''`, + group: 'Identity', + }), + + description: Field.textarea({ + label: 'Description', + required: false, + group: 'Identity', + }), + + object_permissions: Field.textarea({ + label: 'Object Permissions', + required: false, + description: 'JSON-serialized object-level CRUD permissions', + group: 'Permissions', + }), + + field_permissions: Field.textarea({ + label: 'Field Permissions', + required: false, + description: 'JSON-serialized field-level read/write permissions', + group: 'Permissions', + }), + + system_permissions: Field.textarea({ + label: 'System Permissions', + required: false, + description: 'JSON-serialized array of system capability names (e.g. ["setup.access","studio.access","manage_users"])', + group: 'Permissions', + }), + + row_level_security: Field.textarea({ + label: 'Row-Level Security', + required: false, + description: 'JSON-serialized array of row-level security policies (USING/CHECK clauses)', + group: 'Permissions', + }), + + tab_permissions: Field.textarea({ + label: 'Tab Permissions', + required: false, + description: 'JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)', + group: 'Permissions', + }), + + admin_scope: Field.textarea({ + label: 'Delegated Admin Scope', + required: false, + description: + '[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, ' + + 'manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the ' + + 'user a SCOPED administrator within the declared business-unit subtree; enforced by the ' + + 'delegated-admin write gate.', + group: 'Permissions', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + group: 'Status', + }), + + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: + 'Package that ships this permission set (absent = environment-authored). ' + + 'Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined.', + group: 'Provenance', + }), + + managed_by: Field.select({ + label: 'Managed By', + required: false, + readonly: true, + defaultValue: 'admin', + description: + "Record provenance, on the one platform / package / admin vocabulary that capabilities, " + + "permission sets and positions all share: 'platform' = shipped by the " + + "platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly " + + "for admins); 'admin' = created/owned in this environment by an administrator " + + "(live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + group: 'Provenance', + }), + + customized: Field.boolean({ + label: 'Customized', + defaultValue: false, + readonly: true, + description: + 'This packaged permission set has an environment customization overlay (ADR-0094). ' + + 'Reset it (delete through the data door) to return to the shipped baseline.', + group: 'Provenance', + }), + + drift_status: Field.select({ + label: 'Declaration Drift', + required: false, + readonly: true, + description: + "Cause of a declared≠enforced mismatch for this package-declared set. 'overlay_shadow' = a Studio-" + + "authored environment overlay is shadowing the packaged declaration. 'provenance_skip' = this row predates package provenance tracking, so boot sync " + + "treats it as environment-authored and never reconciles it with the package (no automated fix — file " + + "an issue / see the ops runbook for the manual adoption recipe). Absent = enforced grants match the " + + "shipped artifact, or this set is not currently package-declared.", + options: [ + { value: 'overlay_shadow', label: 'Overlay shadow' }, + { value: 'provenance_skip', label: 'Provenance skip' }, + { value: 'other', label: 'Drift (other)' }, + ], + group: 'Provenance', + }), + + drift_detail: Field.textarea({ + label: 'Drift Detail', + required: false, + readonly: true, + description: 'Human-readable detail for `drift_status` — names the grant-count mismatch and its cause.', + group: 'Provenance', + }), + + id: Field.text({ + label: 'Permission Set ID', + required: true, + readonly: true, + group: 'System', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +export const SysPositionPermissionSet = ObjectSchema.create({ + name: 'sys_position_permission_set', + label: 'Position Permission Set', + pluralLabel: 'Position Permission Sets', + isSystem: true, + managedBy: 'system-data', + displayNameField: 'display_title', + nameField: 'display_title', + fields: { + id: Field.text({ + label: 'Binding ID', + required: true, + readonly: true, + description: 'UUID of the position-permission-set binding.', + }), + + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.position_id + ' → ' + record.permission_set_id`, + description: 'Record title: the position and the permission set bound to it (computed on read)', + }), + + position_id: Field.lookup('sys_position', { + label: 'Position', + required: true, + description: 'Foreign key to sys_position.', + }), + + permission_set_id: Field.lookup('sys_permission_set', { + label: 'Permission Set', + required: true, + description: 'Foreign key to sys_permission_set.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['position_id', 'permission_set_id'], unique: 'global' }, + { fields: ['position_id'] }, + { fields: ['permission_set_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + }, +}); + +/** The four retired table shapes above. */ +export const LEGACY_CATALOG_OBJECTS = [SysPosition, SysCapability, SysPermissionSet, SysPositionPermissionSet] as const; + +/** + * `objects` plus every retired catalog table it does not already declare — for + * a suite that boots the plugin's own objects AND models an existing database's + * catalog rows, whether or not the plugin still declares those tables. + */ +export function withLegacyCatalogTables(objects: readonly T[]): Array { + const declared = new Set(objects.map((o) => o.name)); + return [...objects, ...LEGACY_CATALOG_OBJECTS.filter((o) => !declared.has(o.name))]; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 42c76eb2cad..b826db26169 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -50,7 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index 6ab7ae983cb..61b13fcb7f4 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -79,7 +79,7 @@ import { PLATFORM_ADMIN_GRANT_PAGE_SIZE, PLATFORM_ADMIN_GRANT_SCAN_CEILING, } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts new file mode 100644 index 00000000000..a49c03de00e --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A fresh `single`-posture database promotes its first human — on an engine + * that carries exactly the objects the security plugin registers, and nothing + * an earlier release left behind. + * + * The bootstrap's "already have an administrator?" question has a leg for a + * grant written before `sys_user_permission_set.permission_set` existed (it + * names nothing yet, so the by-name legs cannot see it; ADR-0131 D4). That leg + * used to reach such a grant through the legacy `admin_full_access` catalog + * row, read with "refused is not absent": a read that threw withheld the + * promotion. Once the catalog row objects are retired (ADR-0131 D3, C3 stage 8) + * a read of that row throws on EVERY boot, so a leg that still asked it would + * withhold the first administrator from every fresh deployment, forever. This + * suite registers the plugin's own objects only — no legacy table — so that + * regression is a red here. + * + * Its counterpart, the grant that names nothing and must still keep a second + * administrator from being minted, is pinned in + * `grant-readers-unnamed-grant.test.ts`. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { securityObjects } from './manifest.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as const; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; + +const engines: ObjectQL[] = []; + +beforeEach(() => { + delete process.env[OWNER_ENV]; + delete process.env[POSTURE_ENV]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + while (engines.length) { + try { + await engines.pop()?.destroy(); + } catch { + /* noop */ + } + } + resetPlatformAdminEmailMemo(); +}); + +/** A fresh `:memory:` database holding the security plugin's own objects and the identity tables. */ +async function freshEngine(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.fresh-boot-promotion', + name: 'Fresh boot promotion', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, ...securityObjects], + } as any); + await engine.syncSchemas(); + engines.push(engine); + return engine; +} + +async function human(engine: ObjectQL, id: string, email: string, createdAt: string): Promise { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt }, SYS); + await engine.insert('sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, SYS); +} + +describe('[ADR-0131 D3/D4] a fresh single-posture database promotes its first human', () => { + it('promotes the oldest account that can sign in, granting admin_full_access by name', async () => { + const engine = await freshEngine(); + await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); + await human(engine, 'usr_second', 'second@fresh.example', '2026-02-01T00:00:00.000Z'); + + const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + + expect(report).toMatchObject({ adminPromoted: true, adminUserId: 'usr_first' }); + const grants = (await engine.find('sys_user_permission_set', { where: {}, ...SYS })) as any[]; + expect(grants.map((g) => [g.user_id, g.permission_set, g.organization_id ?? null])).toEqual([ + ['usr_first', 'admin_full_access', null], + ]); + }); + + it('a second pass finds the administrator it promoted and promotes nobody else', async () => { + const engine = await freshEngine(); + await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); + expect((await bootstrapPlatformAdmin(engine, defaultPermissionSets)).adminPromoted).toBe(true); + await human(engine, 'usr_later', 'later@fresh.example', '2026-03-01T00:00:00.000Z'); + + const again = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + + expect(again).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_first' }); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 0d0fec1689c..446339ba5f5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -70,7 +70,7 @@ import { PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts deleted file mode 100644 index d57f5e44193..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#22169] `bootstrapPlatformAdmin`'s existence read: a read that did not - * answer is not an absent row. - * - * The seed loop reads each default permission set BY NAME and inserts when it - * finds none. Its `tryFind` answers `[]` for a refused read as well as for an - * empty table, and the loop inserted on both — so on a table without the - * per-organization unique index a refused read minted a second row of a set - * that already existed, and a table holding such duplicates can never build - * that index again. Measured on a real engine with that index dropped: two - * declared sets, two INSERTs, under a refused read. - * - * The double below has no `update` or `delete` member — the seed pass without - * `resync` calls neither — so it is not an engine double those dispatch - * contracts govern. Its `find` answers from its rows, bounded by the caller's - * `limit`; the refused read is a spy laid over it, so the two inputs the pass - * must tell apart — an answer, and a throw — come from one table. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; - -afterEach(() => { vi.restoreAllMocks(); }); - -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - const inserted: any[] = []; - return { - rows, - inserted, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return r[k] === v; - })); - return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - inserted.push({ ...data }); - rows.push({ ...data }); - return { id: data.id }; - }, - }; -} - -/** Every `sys_permission_set` read now throws; every other read still answers. */ -function refuseSetReads(ql: ReturnType): void { - const answer = ql.find.bind(ql); - vi.spyOn(ql, 'find').mockImplementation(async (object: string, q: any) => { - if (object === 'sys_permission_set') throw new Error('fake outage: sys_permission_set read refused'); - return answer(object, q); - }); -} - -const set = (name: string) => ({ name, label: name, objects: {}, systemPermissions: [] }) as any; - -function capture() { - const warns: Array<{ msg: string; meta?: any }> = []; - return { warns, logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } }; -} - -describe('#22169 — bootstrapPlatformAdmin declines the insert when its existence read was refused', () => { - it('a refused read inserts NOTHING, and says so once for the whole pass', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'admin_full_access' }]); - refuseSetReads(ql); - const { warns, logger } = capture(); - - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default'), set('viewer_readonly')], { logger }); - - expect(ql.inserted, '⛔ no row minted on a read that did not answer').toEqual([]); - expect(r.seeded).toBe(0); - const lines = warns.filter((w) => w.msg.includes('could not be read')); - expect(lines, 'one line per pass, never one per set').toHaveLength(1); - expect(lines[0]!.meta).toMatchObject({ - unreadable: 3, total: 3, names: ['admin_full_access', 'member_default', 'viewer_readonly'], - }); - // The promotion half reports the read that failed, not a set that is "missing". - expect(r.reason).toBe('admin_permission_set_unreadable'); - }); - - it('control: an EMPTY answer is still "absent" — a fresh install seeds every default set', async () => { - const ql = makeQl(); - const { warns, logger } = capture(); - - const r = await bootstrapPlatformAdmin(ql, [set('admin_full_access'), set('member_default')], { logger }); - - expect(ql.inserted.map((row) => row.name)).toEqual(['admin_full_access', 'member_default']); - expect(r.seeded).toBe(2); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); - - it('control: an existing row is found and left alone — no insert, no unreadable line', async () => { - const ql = makeQl([{ id: 'ps_existing', name: 'member_default' }]); - const { warns, logger } = capture(); - - await bootstrapPlatformAdmin(ql, [set('member_default')], { logger }); - - expect(ql.inserted).toEqual([]); - expect(warns.filter((w) => w.msg.includes('could not be read'))).toEqual([]); - }); -}); diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts index e81d0383373..ae416c5c67c 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -184,8 +184,13 @@ async function boot(filename: string, posture: Posture, declared: Declared): Pro return { engine, metadata, warnings, writes }; } -/** The `sys_capability` rows, one string per row. */ +/** + * The `sys_capability` rows, one string per row. [C3 stage 8] Once the row + * object is retired (ADR-0131 D3) no such table is registered, so there is no + * row to count. + */ async function census(engine: ObjectQL): Promise { + if (!(engine as any).getObject?.('sys_capability')) return []; const rows = (await engine.find('sys_capability', { where: {}, limit: 5000, context: SYS })) as any[]; return rows .map((r) => [r.name, r.managed_by, r.package_id ?? '-', r.label, r.description ?? '-', r.scope, r.active].join(' | ')) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a0291771532..3cf73127a45 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -64,6 +64,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; +import { withLegacyCatalogTables } from './__tests__/legacy-catalog-objects.testkit.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; const SYS = { isSystem: true } as const; @@ -214,7 +215,8 @@ async function boot(scenario: Scenario): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + // The retired catalog tables too: an existing database still holds their rows. + objects: withLegacyCatalogTables([SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects]), } as any); await engine.syncSchemas(); if (scenario.authoredPosition) { diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index f1d2df6383e..4378b81ddfe 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -39,10 +39,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { @@ -393,30 +393,13 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names const report = await b.report(); expect(report.conflictingPositionNames).toEqual([ { name: PKG_POSITION, organizationIds: [null, ORG], resolves: true }, - { name: 'twin_lead', organizationIds: [ORG, OTHER_ORG], resolves: true }, + // `resolves` for this one is the row-only position backfill's doing, + // which C3 stage 8 unwires, so it is not asserted. + expect.objectContaining({ name: 'twin_lead', organizationIds: [ORG, OTHER_ORG] }), ]); const conflictLine = b.logger.error.mock.calls.find(([, , meta]: any[]) => meta?.conflicting); expect(conflictLine?.[2]?.conflicting?.items?.map((c: any) => c.name)).toEqual([PKG_POSITION, 'twin_lead']); }); - - it('under single, the refused-name class stays listed at every boot, after the row-only backfill has recorded its verdict', async () => { - const b = await boot(); - await insertRows(b, 'sys_position', [ - { id: 'pos_refused', name: 'Legacy Lead', label: 'Illegal name', organization_id: ORG }, - ]); - await b.bootstrapped(); - const ledger = (await b.engine.find('sys_migration', { - where: { id: 'adr-0131-position-environment-backfill' }, context: SYS, - })) as any[]; - expect(ledger.length, 'the backfill recorded its verdict: the refused-name class is final for it').toBe(1); - - await b.bootstrapped(); - expect(findings(await b.report())).toEqual([{ - organizationId: ORG, - positionAssignments: [], permissionSetGrants: [], unnamedGrants: [], - positionsWithoutDefinition: ['Legacy Lead (refused name)'], - }]); - }); }); describe('[ADR-0131 D4] a removed declaration is reported; re-adding it clears the report', () => { @@ -533,14 +516,12 @@ describe('[ADR-0131 D4] a read that did not happen is never "resolves nowhere"', }); describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped', () => { - it('after both backfills — a name either one resolved this boot is not reported — and the report writes nothing', async () => { + it('after the grant-name backfill — a name it resolved this boot is not reported — and the report writes nothing', async () => { const b = await boot(); - // A row-only legal position the position backfill defines this boot, and - // an unnamed grant whose set row's name the catalog resolves, which the - // grant-name backfill names this boot. Neither may be reported. - await insertRows(b, 'sys_position', [{ id: 'pos_legacy', name: 'legacy_lead', label: 'Legacy lead', organization_id: ORG }]); + // An unnamed grant whose set row's name the catalog resolves, which the + // grant-name backfill names this boot: it may not be reported. (The + // row-only position backfill ran here too until C3 stage 8 unwired it.) await insertRows(b, 'sys_user_position', [ - { id: 'up_legacy', user_id: 'usr_a', position: 'legacy_lead', organization_id: ORG }, { id: 'up_ghost', user_id: 'usr_a', position: 'ghost_lead', organization_id: ORG }, ]); await insertRows(b, 'sys_permission_set', [{ id: 'ps_auditor', name: AUDITOR.name, label: 'Auditor' }]); @@ -549,7 +530,7 @@ describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped ]); const handlers = b.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers.length).toBeGreaterThanOrEqual(3); + expect(handlers.length).toBeGreaterThanOrEqual(2); for (const handler of handlers.slice(0, -1)) await handler(); const writes = [ vi.spyOn(b.engine, 'insert'), vi.spyOn(b.engine, 'update'), vi.spyOn(b.engine, 'delete'), diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts index 78bfbbc202d..2f0e5b4516e 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts @@ -301,7 +301,6 @@ describe('the claim target is the existing platform admin, by the bootstrap rule */ async function twoAdmins(file: string) { const engine = await openEngine(file); - await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access', label: 'Admin', active: true }, SYS); for (const [id, createdAt] of [ ['usr_amy', '2026-01-01T00:00:00.000Z'], ['usr_zed', '2026-06-01T00:00:00.000Z'], diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index b2c666f58b2..375c480220f 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -3,8 +3,31 @@ // (ADR-0086 D3 provenance consumed at last; ADR-0090 D5 "no ghost grants"). import { describe, it, expect } from 'vitest'; -import { cleanupPackagePermissions } from './cleanup-package-permissions'; - +import { cleanupPackagePermissions as cleanup } from './cleanup-package-permissions'; + +/** + * The sweep, with the declared-names argument C3 stage 8 adds. Spelled through + * this signature so the suite compiles against the module before and after + * that stage: before it, the sweep finds the package's sets by its catalog + * rows and ignores the names; after it, it finds them by the names. + */ +const cleanupPackagePermissions = cleanup as ( + ql: any, + packageId: string, + logger?: Parameters[2], + declaredSetNames?: readonly string[], +) => ReturnType; + +/** The permission-set names the uninstalled package declared (read off it before its withdrawal). */ +const DECLARED = ['crm_readonly', 'crm_admin']; + +/** + * A store holding both shapes a grant has carried: every grant names its set + * (ADR-0131 D4) and still carries the legacy id of the set's catalog row. The + * catalog rows themselves are here too — what a release before the row objects + * were retired (C3 stage 8) wrote — so the sweep revokes the same grants + * whether it finds the package's sets by its rows or by its declared names. + */ function makeQl() { const tables: Record = { sys_permission_set: [ @@ -17,14 +40,11 @@ function makeQl() { { id: 'ps_env', name: 'member_default', managed_by: 'user' }, { id: 'ps_legacy', name: 'admin_full_access' }, ], - sys_position_permission_set: [ - { id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_crm_ro' }, - { id: 'pps_2', position_id: 'pos_sales', permission_set_id: 'ps_crm_admin' }, - { id: 'pps_other', position_id: 'pos_sales', permission_set_id: 'ps_other' }, - ], sys_user_permission_set: [ - { id: 'ups_1', user_id: 'u1', permission_set_id: 'ps_crm_admin' }, - { id: 'ups_env', user_id: 'u1', permission_set_id: 'ps_env' }, + { id: 'ups_1', user_id: 'u1', permission_set: 'crm_admin', permission_set_id: 'ps_crm_admin' }, + { id: 'ups_2', user_id: 'u2', permission_set: 'crm_readonly', permission_set_id: 'ps_crm_ro' }, + { id: 'ups_env', user_id: 'u1', permission_set: 'member_default', permission_set_id: 'ps_env' }, + { id: 'ups_other', user_id: 'u2', permission_set: 'other_set', permission_set_id: 'ps_other' }, ], sys_audience_binding_suggestion: [ { id: 'sug_1', package_id: 'com.example.crm', permission_set_name: 'crm_readonly', anchor: 'everyone', status: 'confirmed' }, @@ -50,39 +70,41 @@ function makeQl() { } as any; } +const NOTHING = { sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }; + describe('cleanupPackagePermissions (#2747)', () => { - it('removes the package-owned sets, their bindings/grants, and its suggestion rows', async () => { + it('revokes every grant naming a set the package declared, and removes its suggestion rows', async () => { const ql = makeQl(); - const out = await cleanupPackagePermissions(ql, 'com.example.crm'); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); - expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); + expect(out).toMatchObject({ userGrants: 2, suggestions: 2, refused: 0 }); - // no ghost grants: nothing referencing the removed sets survives - expect(ql.tables.sys_permission_set.map((r: any) => r.id)).toEqual(['ps_other', 'ps_env', 'ps_legacy']); - expect(ql.tables.sys_position_permission_set.map((r: any) => r.id)).toEqual(['pps_other']); - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env']); + // no ghost grants: no grant naming the package's sets survives + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other']); expect(ql.tables.sys_audience_binding_suggestion.map((r: any) => r.id)).toEqual(['sug_other']); }); - it('never touches env-authored or foreign-package rows (ADR-0086 D4 provenance)', async () => { + it('never touches env-authored or foreign-package sets or their grants (ADR-0086 D4 provenance)', async () => { const ql = makeQl(); - await cleanupPackagePermissions(ql, 'com.example.crm'); + await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); const names = ql.tables.sys_permission_set.map((r: any) => r.name); expect(names).toContain('other_set'); expect(names).toContain('member_default'); expect(names).toContain('admin_full_access'); + const grants = ql.tables.sys_user_permission_set.map((r: any) => r.permission_set); + expect(grants).toEqual(['member_default', 'other_set']); }); - it('is idempotent — a second run removes nothing', async () => { + it('is idempotent — a second run revokes nothing', async () => { const ql = makeQl(); - await cleanupPackagePermissions(ql, 'com.example.crm'); - const second = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(second).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); + await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); + const second = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, DECLARED); + expect(second).toMatchObject({ userGrants: 0, suggestions: 0, refused: 0 }); }); it('no-ops safely on a missing package id or a non-engine handle', async () => { - expect(await cleanupPackagePermissions(makeQl(), '')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); - expect(await cleanupPackagePermissions(null, 'x')).toEqual({ sets: 0, positionBindings: 0, userGrants: 0, suggestions: 0, refused: 0 }); + expect(await cleanupPackagePermissions(makeQl(), '', undefined, DECLARED)).toEqual(NOTHING); + expect(await cleanupPackagePermissions(null, 'x', undefined, DECLARED)).toEqual(NOTHING); }); // [#12981] The swallow family. `deleteRows` absorbs a refused delete so one @@ -99,7 +121,7 @@ describe('cleanupPackagePermissions (#2747)', () => { } it('counts the refusals instead of reporting a silent zero', async () => { - const refused = await cleanupPackagePermissions(refusingQl(), 'com.example.crm'); + const refused = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', undefined, DECLARED); const nothingToDo = await cleanupPackagePermissions(makeQl(), 'com.unknown.pkg'); // Both revoked nothing. ⛔ Before the repair these two were EQUAL, and @@ -108,8 +130,8 @@ describe('cleanupPackagePermissions (#2747)', () => { expect(nothingToDo.sets + nothingToDo.positionBindings + nothingToDo.userGrants + nothingToDo.suggestions).toBe(0); expect(refused).not.toEqual(nothingToDo); - // 2 sets + 2 position bindings + 1 user grant + 2 suggestion rows. - expect(refused.refused).toBe(7); + // At least the 2 grants and the 2 suggestion rows. + expect(refused.refused).toBeGreaterThanOrEqual(4); expect(nothingToDo.refused).toBe(0); }); @@ -117,7 +139,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const warns: Array<{ msg: string; meta?: any }> = []; const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); }, - }); + }, DECLARED); // ONE line for the whole sweep, not one per refused row. expect(warns).toHaveLength(1); @@ -136,7 +158,7 @@ describe('cleanupPackagePermissions (#2747)', () => { const out = await cleanupPackagePermissions(makeQl(), 'com.example.crm', { warn: (m: string) => { warns.push(m); }, info: (m: string) => { infos.push(m); }, - }); + }, DECLARED); expect(out.refused).toBe(0); expect(warns).toEqual([]); expect(infos).toHaveLength(1); @@ -145,8 +167,8 @@ describe('cleanupPackagePermissions (#2747)', () => { it('survives a host sink that has no warn at all', async () => { // The `?.` on `warn` is the backstop for hosts the TYPE cannot reach. // A refusal report must never become the thing that breaks an uninstall. - const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}); - expect(out.refused).toBe(7); + const out = await cleanupPackagePermissions(refusingQl(), 'com.example.crm', {}, DECLARED); + expect(out.refused).toBeGreaterThanOrEqual(4); }); }); }); diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index e47e483edc3..9c276fd47c1 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -37,9 +37,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 58292fae7aa..010a1d6eb16 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -57,9 +57,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index f8082a3416b..b2f54e68fc7 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -84,9 +84,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index f4af169fbb8..ab25ba84650 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -74,9 +74,9 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index c8714ba0bc6..4aa18030a57 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -43,7 +43,7 @@ import { import { SecurityPlugin } from './security-plugin.js'; import { PermissionEvaluator } from './permission-evaluator.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { SysPermissionSet, SysPosition } from './objects/index.js'; +import { SysUserPosition, SysUserPermissionSet } from './objects/index.js'; /** The metadata-declared baseline every member resolves additively. */ const MEMBER_DEFAULT: PermissionSet = { @@ -335,8 +335,8 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject [SysUser.name]: SysUser, // better-auth, opts `edit` in [SysMember.name]: SysMember, // better-auth, write-denied blanket [SysSecret.name]: SysSecret, // private, engine-owned, named by no shipped set - [SysPosition.name]: SysPosition, // the org admin's read-only RBAC rows - [SysPermissionSet.name]: SysPermissionSet, + [SysUserPosition.name]: SysUserPosition, // the org admin's read-only RBAC rows + [SysUserPermissionSet.name]: SysUserPermissionSet, }; const shipped = (name: string): PermissionSet => { const set = defaultPermissionSets.find((ps) => ps.name === name); @@ -484,12 +484,12 @@ describe('[#20083] parity: can() over the member\'s map answers what checkObject vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!(context)); for (const [verb, operation] of [['edit', 'update'], ['create', 'insert'], ['delete', 'delete']] as const) { - expect(evaluator.checkObjectPermission(operation, SysPosition.name, sets), `${label} ${verb}`).toBe(false); - expect(can(permissions, SysPosition.name, verb), `${label} ${verb}`).toBe(false); + expect(evaluator.checkObjectPermission(operation, SysUserPosition.name, sets), `${label} ${verb}`).toBe(false); + expect(can(permissions, SysUserPosition.name, verb), `${label} ${verb}`).toBe(false); } // …and still reads them, on both sides. - expect(evaluator.checkObjectPermission('find', SysPosition.name, sets)).toBe(true); - expect(can(permissions, SysPosition.name, 'read')).toBe(true); + expect(evaluator.checkObjectPermission('find', SysUserPosition.name, sets)).toBe(true); + expect(can(permissions, SysUserPosition.name, 'read')).toBe(true); } // `sys_user` edit: `member_default` grants it, `organization_admin` alone names it write-denied. const alone = SUBJECTS['[#20136 row b] walled org admin WITHOUT member_default']; diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index f126072ea52..0a237ad9d4a 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -30,10 +30,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { GRANT_HOLDER_MEMBERSHIP_HOOK_PACKAGE, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..e523d81bd0a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -35,10 +35,10 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { @@ -671,13 +671,12 @@ describe('[ADR-0131 D4] grant name backfill — boot wiring', () => { await seedPrincipals(world, 'single'); await clearAllNames(world.engine); - // Three `kernel:bootstrapped` handlers, in registration order: this - // backfill, then the row-only position backfill (ADR-0131 D3, C2 stage S7), - // which `position-write-through.test.ts` drives, then the catalog reference - // report, which `catalog-reference-report.test.ts` drives. Only the first - // runs here. + // The `kernel:bootstrapped` handlers, in registration order: this backfill + // first, the catalog reference report (`catalog-reference-report.test.ts`) + // last — and between them the row-only position backfill until C3 stage 8 + // unwires it. Only the first runs here. const handlers = world.hooks.get('kernel:bootstrapped') ?? []; - expect(handlers).toHaveLength(3); + expect(handlers.length).toBeGreaterThanOrEqual(2); const errorsBefore = world.pluginLogger.error.mock.calls.length; const warningsBefore = world.pluginLogger.warn.mock.calls.length; await handlers[0](); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..f30cfd45c0a 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -45,10 +45,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index c18e01b2191..dfb7e55e27e 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -27,10 +27,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { GRANT_SET_NAME_HOOK_PACKAGE, diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index b34933df9e1..54832eb7429 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -24,7 +24,7 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { SysUser, SysAccount, SysMember } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index 972db17ea4c..73476bc018a 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -45,10 +45,10 @@ import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 266c03ace3f..d4612e588af 100644 --- a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -37,10 +37,10 @@ import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts index ae87e1f221b..2fca4a1fc0c 100644 --- a/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts +++ b/packages/plugins/plugin-security/src/granted-by-writer-provenance.test.ts @@ -41,13 +41,8 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { INVITATION_PLACEMENT_SERVICE, type InvitationPlacementService } from './invitation-placement.js'; -import { - SysPermissionSet, - SysPosition, - SysPositionPermissionSet, - SysUserPermissionSet, - SysUserPosition, -} from './objects/index.js'; +import { SysUserPermissionSet, SysUserPosition } from './objects/index.js'; +import { SysPermissionSet, SysPosition, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; const SYS = { context: { isSystem: true } } as never; diff --git a/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts index 9518fd8bc0a..f6ffd20d45f 100644 --- a/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts +++ b/packages/plugins/plugin-security/src/identifier-storage-ceiling-pin.test.ts @@ -17,9 +17,12 @@ import { SecurityPlugin } from './security-plugin.js'; * purpose: every enforced ceiling on an identifier is the `maxLength` of the * column that stores it (refused at the write seam by ObjectQL's * record-validator `max_length` check), and the storing columns DISAGREE — - * 100 for the three config-object name columns this plugin registers - * (`sys_permission_set.name`, `sys_position.name`, `sys_capability.name`), - * 255 for `sys_metadata.name`. A single shared `.max()` therefore cannot + * 100 for the two catalog-name columns this plugin registers + * (`sys_user_permission_set.permission_set`, `sys_user_position.position`: + * a grant and an assignment name their catalog item, ADR-0131 D4), 255 for + * `sys_metadata.name`, where the catalog definitions themselves are stored. + * [C3 stage 8] The three catalog row objects whose `name` columns this pin + * read before are retired (ADR-0131 D3). A single shared `.max()` therefore cannot * equal every consumer's enforced ceiling: `.max(100)` would newly refuse * `sys_metadata` names in (100, 255] that are legal stored rows today — * accept-set narrowing beyond enforced reality, fenced out of commit 3a04b0125's @@ -82,15 +85,18 @@ async function registeredObjects(): Promise { return captured; } -/** The three config-object name columns this plugin registers. */ -const PLUGIN_NAME_COLUMNS = ['sys_permission_set', 'sys_position', 'sys_capability'] as const; +/** The two catalog-name columns this plugin registers, as `[object, field]`. */ +const PLUGIN_NAME_COLUMNS = [ + ['sys_user_permission_set', 'permission_set'], + ['sys_user_position', 'position'], +] as const; -/** Read `.name`'s declared width, refusing to answer from absence. */ -function nameWidth(o: AnyObject | undefined, label: string): number { - const w = o?.fields?.name?.maxLength; +/** Read `.`'s declared width, refusing to answer from absence. */ +function columnWidth(o: AnyObject | undefined, label: string, field = 'name'): number { + const w = o?.fields?.[field]?.maxLength; expect( typeof w === 'number' && Number.isInteger(w) && w > 0, - `${label}.name must declare a positive integer maxLength (got ${String(w)}) — ` + + `${label}.${field} must declare a positive integer maxLength (got ${String(w)}) — ` + `without it this pin has no width to link the spec schema against`, ).toBe(true); return w as number; @@ -99,13 +105,13 @@ function nameWidth(o: AnyObject | undefined, label: string): number { /** Every measured storing column, width read off its registration surface. */ async function measuredColumns(): Promise> { const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); - const cols = PLUGIN_NAME_COLUMNS.map((n) => ({ - column: `${n}.name`, - width: nameWidth(byName.get(n), n), + const cols = PLUGIN_NAME_COLUMNS.map(([n, field]) => ({ + column: `${n}.${field}`, + width: columnWidth(byName.get(n), n, field), })); cols.push({ column: 'sys_metadata.name', - width: nameWidth(SysMetadataObject as unknown as AnyObject, 'sys_metadata'), + width: columnWidth(SysMetadataObject as unknown as AnyObject, 'sys_metadata'), }); return cols; } @@ -126,7 +132,7 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 it('enumerates a real surface and the probes really measure the schemas — the pin is not vacuous', async () => { const objects = await registeredObjects(); expect(objects.map((o) => o.name)).toEqual( - expect.arrayContaining([...PLUGIN_NAME_COLUMNS]), + expect.arrayContaining(PLUGIN_NAME_COLUMNS.map(([n]) => n)), ); // Instrument honesty: a string of the same width that VIOLATES the @@ -151,9 +157,8 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 // derivable; that is a spec accept-set change to escalate, not a value // to tidy here. const widths = new Map((await measuredColumns()).map((c) => [c.column, c.width])); - expect(widths.get('sys_permission_set.name')).toBe(100); - expect(widths.get('sys_position.name')).toBe(100); - expect(widths.get('sys_capability.name')).toBe(100); + expect(widths.get('sys_user_permission_set.permission_set')).toBe(100); + expect(widths.get('sys_user_position.position')).toBe(100); expect(widths.get('sys_metadata.name')).toBe(255); }); @@ -163,7 +168,7 @@ describe('shared identifier schemas ↔ the storage columns that bound them (#12 // class would newly refuse names that are legal stored rows today — // accept-set narrowing beyond enforced reality. `sys_metadata.name` // (255) is the widest and therefore the binding case: a blanket - // `.max(100)` matching the config-object columns reds here by name. + // `.max(100)` matching the catalog-name columns reds here by name. for (const { column, width } of await measuredColumns()) { for (const [label, schema] of Object.entries(IDENTIFIER_SCHEMAS)) { expect( diff --git a/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts b/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts deleted file mode 100644 index 1eb0c143d91..00000000000 --- a/packages/plugins/plugin-security/src/objects/clone-carryover.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// #11992 — the exemplar half of the carry-over ruling (commit 0e4e51b0a, recommendation A, -// maintainer 2026-08-25): the five `clone_permission_set` facet params DECLARE -// the spec's `carryOver` key, so the clone dialog's JSON facets are copied -// verbatim, shown read-only, and never offered as prefilled textareas an admin -// could hand-mangle into a clone that grants MORE than its base. -// -// ⭐ IDENTITIES, NOT COUNTS (same discipline as commit 5cb62d88b's pins one file over): -// "five params declare it" holds constant while two of them swap. Every facet -// is asserted by NAME, and the deliberate non-member (`description` — prose, -// not a permission facet) is asserted NOT to carry the key, so the boundary of -// the declaration is pinned from both sides. -// -// The SEND side is deliberately not restated here — that is -// `packaged-permission-set-lock.test.ts`'s clone-payload suite (commit 5cb62d88b's pin 6), -// which reads the params list and must stay green under this declaration -// precisely because `carryOver` changes what the dialog RENDERS, never what it -// SENDS. -import { describe, it, expect } from 'vitest'; -import { ActionParamSchema } from '@objectstack/spec/ui'; -import { SysPermissionSet } from './sys-permission-set.object.js'; - -/** The five JSON-serialized definition facets the clone carries (commit 5cb62d88b). */ -const CARRIED_FACETS = [ - 'object_permissions', - 'field_permissions', - 'system_permissions', - 'row_level_security', - 'tab_permissions', -] as const; - -const cloneParams = (): any[] => { - const action = (SysPermissionSet.actions ?? []).find( - (a: any) => a.name === 'clone_permission_set', - ); - if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions'); - return (action as any).params ?? []; -}; - -describe('clone_permission_set carry-over declaration (#11992)', () => { - it.each(CARRIED_FACETS)('%s declares carryOver: true alongside its row seed', (facet) => { - const p = cloneParams().find((x) => x.field === facet); - expect(p, `param { field: '${facet}' } is missing from clone_permission_set`).toBeDefined(); - expect(p.carryOver).toBe(true); - // The co-requirement the spec enforces at parse time — asserted here too - // so a future edit that drops the seed fails THIS suite by name instead of - // only tripping a schema refusal somewhere in a stack build. - expect(p.defaultFromRow).toBe(true); - }); - - it('description stays an ordinary editable param (no carryOver)', () => { - const p = cloneParams().find((x) => x.field === 'description'); - expect(p, 'param { field: "description" } is missing').toBeDefined(); - expect(p.carryOver).toBeUndefined(); - }); - - it('every clone param parses under ActionParamSchema (the declaration is spec-legal, not local dialect)', () => { - for (const p of cloneParams()) { - const r = ActionParamSchema.safeParse(p); - expect( - r.success, - `param ${JSON.stringify(p)} refused: ${JSON.stringify((r as { error?: unknown }).error)}`, - ).toBe(true); - } - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts index 9e112a1d3ab..b26197cb236 100644 --- a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts +++ b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts @@ -34,7 +34,6 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; import { SysUserPosition } from './sys-user-position.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; /** What each object resolved to in v16: LOCKED `system` + `userActions: {c,e,d}`. */ const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; @@ -69,7 +68,6 @@ const asV16 = (obj: { userActions?: unknown }) => ({ const OBJECTS = [ ['sys_user_position', SysUserPosition], ['sys_user_permission_set', SysUserPermissionSet], - ['sys_position_permission_set', SysPositionPermissionSet], ] as const; describe('#3355 — RBAC link tables move to `system-data` with their affordances intact', () => { diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index eb2f6c7155a..10ab00ec7c4 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { SysPosition, SysPermissionSet, SysCapability, SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; +import { SysUserPermissionSet, SysUserPosition, defaultPermissionSets } from './index.js'; /** * RBAC object + default-permission-set assertions. Moved here with the objects @@ -62,10 +62,9 @@ describe('default permission sets', () => { it('organization_admin is read-only on global RBAC tables to prevent privilege escalation', () => { const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!; + // [C3 stage 8] The catalog row objects are retired (ADR-0131 D3); the + // assignment and grant tables are what is left to protect. for (const obj of [ - 'sys_position', - 'sys_permission_set', - 'sys_position_permission_set', 'sys_user_permission_set', 'sys_user_position', ]) { @@ -188,130 +187,6 @@ describe('default permission sets', () => { }); }); -describe('RBAC object canonical names + row actions', () => { - it('SysPosition / SysPermissionSet use their canonical sys_ short names and are system objects', () => { - expect(SysPosition.name).toBe('sys_position'); - expect(SysPermissionSet.name).toBe('sys_permission_set'); - expect(SysPosition.isSystem).toBe(true); - expect(SysPermissionSet.isSystem).toBe(true); - }); - - it('SysPosition exposes activate/deactivate/clone/set-default row actions (ADR-0090 D3 vocabulary)', () => { - const names = (SysPosition.actions ?? []).map((a) => a.name).sort(); - expect(names).toEqual(['activate_position', 'clone_position', 'deactivate_position', 'set_default_position']); - }); - - it('SysPermissionSet exposes activate/deactivate/clone row actions', () => { - const names = (SysPermissionSet.actions ?? []).map((a) => a.name).sort(); - expect(names).toEqual([ - 'activate_permission_set', 'clone_permission_set', 'deactivate_permission_set', - ]); - }); - - it('[field report — rc→GA declared≠enforced surfacing] SysPermissionSet.drift_status is a select naming the two detected causes, and appears in the "Needs Attention" list view', () => { - const f: any = (SysPermissionSet.fields as any).drift_status; - expect(f, 'drift_status field exists').toBeDefined(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['other', 'overlay_shadow', 'provenance_skip']); - - const views: any = SysPermissionSet.listViews; - expect(views.drifted, 'drifted list view exists').toBeDefined(); - expect(views.drifted.columns).toContain('drift_status'); - expect(views.all_permsets.columns).toContain('drift_status'); - }); - - it('[A4 #2920] SysPermissionSet.managed_by is a select on the unified platform/package/admin vocab', () => { - const f: any = (SysPermissionSet.fields as any).managed_by; - expect(f, 'managed_by field exists').toBeDefined(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['admin', 'package', 'platform']); - }); - - it('[A4 #2920] SysPosition.managed_by is a select on the unified vocab and appears in every list view', () => { - const f: any = (SysPosition.fields as any).managed_by; - expect(f, 'managed_by field exists').toBeDefined(); - expect(f.type).toBe('select'); - expect(f.readonly).toBe(true); - const opts = (f.options ?? []).map((o: any) => o.value).sort(); - expect(opts).toEqual(['admin', 'package', 'platform']); - // The provenance column is surfaced in all four position list views. - const views: any = SysPosition.listViews; - for (const v of ['active', 'default_positions', 'custom', 'all_positions']) { - expect(views[v].columns, `${v} view shows managed_by`).toContain('managed_by'); - } - }); - - it('[A4 #2920] all three RBAC catalogs share the identical managed_by vocabulary', () => { - const vocab = (schema: any) => - ((schema.fields.managed_by.options ?? []) as any[]).map((o) => o.value).sort(); - const cap = vocab(SysCapability); - expect(cap).toEqual(['admin', 'package', 'platform']); - expect(vocab(SysPermissionSet)).toEqual(cap); - expect(vocab(SysPosition)).toEqual(cap); - }); - - it('[ADR-0094] declares a readonly `customized` provenance flag surfaced in the All list view', () => { - const f: any = (SysPermissionSet.fields as any).customized; - expect(f, 'customized field exists').toBeDefined(); - expect(f.type).toBe('boolean'); - expect(f.readonly).toBe(true); - const allView: any = (SysPermissionSet.listViews as any).all_permsets; - expect(allView.columns).toContain('customized'); - expect(allView.columns).toContain('managed_by'); - }); - - it('[ADR-0094] locks the API name after creation (readonly on edit, editable on create)', () => { - // The name is the metadata identity the record projects from — renaming - // through the data door is rejected (400); this is the matching UI lock. - const nameField: any = (SysPermissionSet.fields as any).name; - expect(nameField.readonlyWhen, 'name carries a readonlyWhen lock').toBeTruthy(); - // The predicate keys off the server-assigned id: absent on create, present - // on edit — so create stays editable and edit is locked. - const pred = JSON.stringify(nameField.readonlyWhen); - expect(pred).toContain('record.id'); - // The static readonly flag is NOT set (that would block create too). - expect(nameField.readonly ?? false).toBe(false); - }); -}); - - -describe('sys_capability — ADR-0066 D1 capability registry', () => { - it('is a system config object with the canonical name', () => { - expect(SysCapability.name).toBe('sys_capability'); - expect(SysCapability.isSystem).toBe(true); - expect(SysCapability.managedBy).toBe('config'); - }); - - it('declares name/label/scope/managed_by fields', () => { - const f: any = SysCapability.fields; - expect(f.name).toBeDefined(); - expect(f.label).toBeDefined(); - expect(f.scope).toBeDefined(); - expect(f.managed_by).toBeDefined(); - // scope + managed_by are constrained selects - const scopeOpts = (f.scope.options ?? []).map((o: any) => o.value).sort(); - expect(scopeOpts).toEqual(['org', 'platform']); - const mbOpts = (f.managed_by.options ?? []).map((o: any) => o.value).sort(); - expect(mbOpts).toEqual(['admin', 'package', 'platform']); - }); - - it('enforces a unique index on name, scoped per organization', () => { - const nameIdx = (SysCapability.indexes ?? []).find((i: any) => Array.isArray(i.fields) && i.fields.includes('name')); - // [#8323] Was `toBe(true)`. On a DECLARED index bare `true` is the - // positional spelling of `'global'` — the listed columns verbatim — which - // made `name` an installation-wide key on a tenant-scoped object and turned - // its 409 into a cross-tenant existence oracle. The assertion is respelled - // rather than relaxed: a truthiness check here would accept the very - // spelling that was the defect. Full contract in - // `sys-capability.organization-unique.test.ts`. - expect(nameIdx?.unique).toBe('organization'); - }); -}); - describe('sys_user_permission_set — `delegated_from` is retired (#9730, ADR-0049)', () => { it('no longer declares `delegated_from` — the runtime delegation gate never read it here', () => { // Maintainer ruling 2026-08-18 (REMOVE): the delegation gate's @@ -333,31 +208,3 @@ describe('sys_user_permission_set — `delegated_from` is retired (#9730, ADR-00 expect(f.reference).toBe('sys_user'); }); }); - -describe('sys_position — `permissions` is retired (#9885, ADR-0049)', () => { - it('no longer declares `permissions` — nothing ever wrote or read it', () => { - // Maintainer ruling 2026-08-20 (REMOVE): the object-scoped census read - // every sys_position-naming file — the bootstrap writers set label / - // description / managed_by / active / is_default only, and position→grant - // resolution consults `sys_position_permission_set` rows plus the position - // `name`, never a `permissions` column. A free-text grant catalogue no - // runtime enforces tells an author that direct position-level grants - // exist; they do not. Re-declaring the key here without a runtime reader - // in the same change is the exact defect the ruling removed; this pin - // makes that re-growth loud. - expect(SysPosition.fields).not.toHaveProperty('permissions'); - }); - - it('clone_position no longer copies the retired column (defaultFromRow itself survives)', () => { - const clone: any = (SysPosition.actions ?? []).find((a: any) => a.name === 'clone_position'); - expect(clone, 'clone_position action still declared').toBeDefined(); - const copied = ((clone.params ?? []) as any[]) - .filter((p) => typeof p.field === 'string') - .map((p) => p.field); - // Positive control first: the copy mechanism is untouched — only the - // retired column left it. A clone that stopped copying `description` - // would be a different regression, not this retirement. - expect(copied).toContain('description'); - expect(copied).not.toContain('permissions'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index 1274325e039..a486114be2b 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -35,10 +35,7 @@ import { ObjectQL, VALIDATION_FAILED_CODE } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; -import { SysPosition } from './sys-position.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; -import { SysPermissionSet } from './sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; import { RESERVED_IDENTITY_NAMES, isReservedIdentityName, @@ -67,7 +64,7 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet], + objects: [SysUserPosition], } as any); await engine.syncSchemas(); engines.push(engine); @@ -130,107 +127,29 @@ describe('#15972 the reserved set is IMPORTED from the spec constant, never rety }); }); -describe('#15972 both write doors read the GENERATED list — one predicate, no second copy', () => { - const positionRule: any = (SysPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_name'); +describe('#15972 the assignment door reads the GENERATED list — one predicate, no second copy', () => { + // [C3 stage 8] The `sys_position` row object and its copy of the rule are + // retired (ADR-0131 D3): a position is a catalog definition, refused by the + // metadata door. The assignment row keeps the rule. const assignmentRule: any = (SysUserPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_position'); - it('sys_position declares the rule, at `error` severity', () => { - expect(positionRule).toBeDefined(); - // ⛔ `warning` / `info` are LOGGED and never throw — a reserved-name rule at - // either level is a rule that refuses nothing. - expect(positionRule.severity).toBe('error'); - expect(positionRule.condition.source).toContain(reservedIdentityNamesCelList()); - }); - it('sys_user_position declares the rule, at `error` severity', () => { expect(assignmentRule).toBeDefined(); + // ⛔ `warning` / `info` are LOGGED and never throw — a reserved-name rule at + // either level is a rule that refuses nothing. expect(assignmentRule.severity).toBe('error'); expect(assignmentRule.condition.source).toContain(reservedIdentityNamesCelList()); }); - it('both carry the SAME wording, parameterised only by the column', () => { - expect(positionRule.message).toBe(reservedIdentityNameMessage('name')); + it('carries the shared wording, parameterised by the column', () => { expect(assignmentRule.message).toBe(reservedIdentityNameMessage('position')); }); - it('only sys_position takes a provenance exemption — the assignment door takes none', () => { - // The asymmetry is the design: `sys_position` has a legitimate platform - // seeder for exactly these names; `sys_user_position` has none, in any - // package, so its refusal is unconditional. - expect(positionRule.condition.source).toContain('managed_by'); + it('takes no provenance exemption', () => { expect(assignmentRule.condition.source).not.toContain('managed_by'); }); }); -describe('#15972 sys_position — the object layer refuses the reserved names on a real engine', () => { - it.each([...BUILTIN_IDENTITY_NAMES])('refuses a tenant-authored position named %s', async (name) => { - const engine = await boot(); - const err = await refusalOf(() => (engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: 'Impostor' }, - { context: TENANT_CTX }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - expect(err.message).toContain('framework-reserved built-in identity name'); - }); - - it('NEGATIVE CONTROL — every other name still writes', async () => { - const engine = await boot(); - // Including the shapes a pattern-based guard would have swallowed. - for (const name of ['sales_manager', 'org_manager', 'platform_admin_deputy', 'hr_specialist']) { - await expect((engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: name }, - { context: TENANT_CTX }, - )).resolves.toBeTruthy(); - } - }); - - it('refuses a RENAME into a reserved name (the update door, not just insert)', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_position', { id: 'pos_rename', name: 'sales_manager', label: 'Sales' }, { context: TENANT_CTX }, - ); - const err = await refusalOf(() => (engine as any).update( - 'sys_position', { id: 'pos_rename', name: 'platform_admin' }, { context: TENANT_CTX }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - }); - - it('refuses a PACKAGE-provenance row too — a stack may not repurpose one either', async () => { - const engine = await boot(); - const err = await refusalOf(() => (engine as any).insert( - 'sys_position', - { id: 'pos_pkg', name: 'org_admin', label: 'Impostor', managed_by: 'package' }, - { context: { isSystem: true } }, - )); - expect(err.code).toBe(VALIDATION_FAILED_CODE); - }); - - it("a PLATFORM-provenance row spelling a reserved name still writes — the exemption's one holder", async () => { - const engine = await boot(); - // [ADR-0131 D2] The built-in seeder that wrote these rows is retired: the - // six built-ins are declared metadata and the boot writes no row. Rows it - // wrote stay on existing databases (C7 owns their removal), stamped - // `managed_by: 'platform'` under an `isSystem` context, and the exemption - // keeps them writable — an insert of that shape, and the UPDATE leg over a - // row that already spells a reserved name, must not start refusing. - for (const name of BUILTIN_IDENTITY_NAMES) { - await (engine as any).insert( - 'sys_position', - { id: `pos_${name}`, name, label: name, managed_by: 'platform', active: true, is_default: false }, - { context: { isSystem: true } }, - ); - } - const rows = await (engine as any).find('sys_position', { where: {}, context: { isSystem: true } }); - const written = rows.map((r: any) => r.name); - for (const name of BUILTIN_IDENTITY_NAMES) expect(written).toContain(name); - await expect((engine as any).update( - 'sys_position', { id: 'pos_org_admin', label: 'Organization Admin' }, { context: { isSystem: true } }, - )).resolves.toBeTruthy(); - }); -}); - describe('#15972 sys_user_position — the ROW the card is about', () => { it.each([...BUILTIN_IDENTITY_NAMES])('refuses an assignment row spelling %s', async (name) => { const engine = await boot(); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts deleted file mode 100644 index 07f812be8b4..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability-deactivation-claim.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#8535] `sys_capability.active` claims nothing about authorization — on ANY -// shipped surface, in ANY locale. -// -// The defect this pins: `deactivate_capability`'s confirmation dialog told the -// admin "Grants and resource requirements that reference it stop resolving until -// re-activated." Nothing enforced that. `PermissionEvaluator.getSystemPermissions()` -// unions `permissionSets[].systemPermissions` — plain strings — and a resource's -// `requiredPermissions` is matched against that string set; neither loads a -// `sys_capability` row. The table's only two production readers are seeders, which -// WRITE `active: true` on insert and never read it back. So an admin was told a -// withdrawal took effect and it silently did not — the escalation is what they -// believed they had prevented. -// -// The maintainer ruled (2026-08-13) that ENFORCEMENT is not the answer: putting the -// registry on the authorization hot path is an architectural change that needs its -// own designed card. The claim is withdrawn instead. -// -// ── Why this file is a SWEEP and not a pin ────────────────────────────────── -// Asserting the new wording would be the weak test: it passes the moment the new -// sentence exists, on the one surface it names, and says nothing about the five -// other places the old sentence was ALSO shipped. This walks every string on every -// surface — the object definition and all four locale bundles — and applies two -// independent checks to each: -// -// NEGATIVE the withdrawn claim's own words, per locale, appear nowhere. Catches -// a surface that was missed, and a surface added later that copies the -// old text back in. -// POSITIVE every locale's dialog and field help actually STATE the non-effect. -// Catches the cheap non-fix — deleting the false sentence and leaving -// an admin who remembers it to infer the rest — which the negative -// check alone would pass. -// -// The negative check cannot catch an arbitrary paraphrase of the falsehood in a -// language it does not read; that is why the positive check carries equal weight. -// -// If capability lifecycle ever becomes genuinely enforceable (its own card, per the -// ruling), this file is what must change with it — the wording is only true while -// `active` is unread. - -import { describe, it, expect } from 'vitest'; -import { SysCapability } from './sys-capability.object.js'; -import { enObjects } from '../translations/en.objects.generated.js'; -import { esESObjects } from '../translations/es-ES.objects.generated.js'; -import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; -import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; - -/** Every `[path, value]` string reachable in a plain-data tree. */ -function walkStrings(node: unknown, path = ''): Array<[string, string]> { - if (typeof node === 'string') return [[path, node]]; - if (Array.isArray(node)) return node.flatMap((v, i) => walkStrings(v, `${path}[${i}]`)); - if (node && typeof node === 'object') { - return Object.entries(node as Record).flatMap(([k, v]) => - walkStrings(v, path ? `${path}.${k}` : k), - ); - } - return []; -} - -/** - * The withdrawn claim, in the exact words each locale shipped it in (taken - * verbatim from the pre-fix bundles). A surface carrying any of these is - * re-asserting that deactivation withdraws access. - */ -const WITHDRAWN_CLAIM: Record = { - en: [/stop resolving/i, /until re-activated/i], - 'es-ES': [/dejar[áa]n de resolverse/i, /hasta que se reactive/i], - 'ja-JP': [/解決されなくなります/], - 'zh-CN': [/无法解析/], -}; - -/** The honest statement each locale must actually make (not merely omit the lie). */ -const NON_EFFECT_MARKER: Record = { - en: /authorization is not affected|no authorization effect/i, - 'es-ES': /no se ve afectada|no tiene ning[úu]n efecto sobre la autorizaci[óo]n/i, - 'ja-JP': /認可には(影響しません|一切影響しません)/, - 'zh-CN': /授权(不受影响|没有任何影响)/, -}; - -const BUNDLES: Array<[string, Record]> = [ - ['en', enObjects], - ['es-ES', esESObjects], - ['ja-JP', jaJPObjects], - ['zh-CN', zhCNObjects], -]; - -describe('[#8535] sys_capability deactivation claims no authorization effect', () => { - // ── NEGATIVE sweep ──────────────────────────────────────────────────────── - it('the object definition carries the withdrawn claim on no string at all', () => { - const offenders = walkStrings(SysCapability) - .filter(([, v]) => WITHDRAWN_CLAIM.en.some((re) => re.test(v))) - .map(([p, v]) => `${p}: ${v}`); - expect( - offenders, - 'a string on SysCapability states that deactivation stops grants/requirements resolving. ' + - 'Nothing enforces that (getSystemPermissions unions permission-set strings; no row is ever ' + - 'read). Either withdraw the claim, or land enforcement as its own card first.', - ).toEqual([]); - }); - - it.each(BUNDLES)('the %s bundle carries the withdrawn claim on no string at all', (locale, bundle) => { - const patterns = WITHDRAWN_CLAIM[locale]; - expect(patterns, `no withdrawn-claim patterns registered for locale ${locale}`).toBeDefined(); - const offenders = walkStrings(bundle.sys_capability, `${locale}.sys_capability`) - .filter(([, v]) => patterns.some((re) => re.test(v))) - .map(([p, v]) => `${p}: ${v}`); - expect( - offenders, - `the ${locale} bundle still ships the withdrawn claim. Editing the source object does NOT ` + - 'rewrite shipped bundles — `check-i18n-bundles.mjs --write` reports "regenerated" and leaves ' + - 'changed leaf values untouched, so this locale must be corrected by hand.', - ).toEqual([]); - }); - - // ── POSITIVE sweep ──────────────────────────────────────────────────────── - it('the source dialog states the non-effect outright', () => { - const action: any = (SysCapability.actions ?? []).find((a: any) => a.name === 'deactivate_capability'); - expect(action, 'deactivate_capability action exists').toBeDefined(); - expect( - action.confirmText, - 'the dialog must SAY authorization is unaffected, not merely stop lying about it — an admin ' + - 'who remembers the old wording has to be told it was wrong.', - ).toMatch(NON_EFFECT_MARKER.en); - }); - - it.each(BUNDLES)('the %s dialog states the non-effect outright', (locale, bundle) => { - const confirmText = bundle.sys_capability?._actions?.deactivate_capability?.confirmText; - expect(confirmText, `${locale} bundle has a deactivate_capability confirmText`).toBeTruthy(); - expect(confirmText).toMatch(NON_EFFECT_MARKER[locale]); - }); - - it('the active field documents its own inertness at the source', () => { - const active: any = (SysCapability.fields as any).active; - expect(active, 'active field exists').toBeDefined(); - expect( - active.description, - 'the field carried NO description at all, which is how the dialog became the only place its ' + - 'meaning was stated — and that statement was false.', - ).toMatch(NON_EFFECT_MARKER.en); - }); - - it.each(BUNDLES)('the %s bundle translates the active field help', (locale, bundle) => { - const help = bundle.sys_capability?.fields?.active?.help; - expect(help, `${locale} bundle has help for the active field`).toBeTruthy(); - expect( - help, - `${locale} still carries the English fill for active.help — 'os i18n extract' seeds a NEW key ` + - 'with the default-locale string, and leaving it is how a locale ships an untranslated surface.', - ).toMatch(NON_EFFECT_MARKER[locale]); - }); - - // ── Demotion from prominence (the other half of the ruling) ──────────────── - it('active is not a highlight field', () => { - expect( - SysCapability.highlightFields ?? [], - 'record-header prominence beside scope/managed_by is itself a claim that the flag belongs to ' + - 'the authorization posture — a truthful dialog under a first-class field still says it matters.', - ).not.toContain('active'); - }); - - it.each(['platform', 'org'])('the %s list view does not surface active as a column', (view) => { - const columns: string[] = ((SysCapability.listViews as any)[view]?.columns ?? []) as string[]; - expect(columns.length, `${view} view has columns`).toBeGreaterThan(0); - expect(columns).not.toContain('active'); - }); - - it('the full-catalogue view DOES still surface active', () => { - // Deliberately asserted: hiding a flag the product still lets an admin set is - // the opposite error, not a stronger fix. A catalogue attribute stays visible - // in the catalogue view. - expect((SysCapability.listViews as any).all_capabilities?.columns ?? []).toContain('active'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts deleted file mode 100644 index 34ec75602f4..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { SysCapability } from './sys-capability.object'; - -/** - * #8323 — `sys_capability.name` is unique per ORGANIZATION, not per - * installation. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured on an `isolated` deployment: an organization - * could POST a capability name and read `409` vs `201` to learn whether ANY - * other organization — or the platform seed — already held it, while its own - * `GET` on that name returned `total 0`. A refusal on a row you are not - * permitted to see is an existence oracle, and under the posture sold as - * "legal-entity / sovereignty isolation" it crosses the wall it advertises. - * - * ADR-0066 D1 is the reason per-organization is the CORRECT boundary and not - * merely the safe one: the platform DEFINES capabilities and admins EXTEND them - * in Setup (`managed_by: 'admin'`, `scope: 'org'`), so two organizations - * naming a capability the same way are not in conflict. - * - * Platform-seeded rows carry no organization and the organization key part is - * NULL-safe (`COALESCE(organization_id,'__global__')`, ADR-0120 D3), so they - * remain unique among themselves. That behaviour is pinned driver-side in - * `driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts`; - * this test pins the declaration its fixture copies. - */ -describe('sys_capability — declared uniqueness is organization-scoped (#8323)', () => { - const uniqueIndexes = (SysCapability.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect(uniqueIndexes[0].fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. - expect(uniqueIndexes[0].unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('leaves the non-unique indexes alone', () => { - // Scope discipline: #8323 changes uniqueness scope, nothing else. The - // `package_id` index is ADR-0086 D3's uninstall/upgrade query. - expect((SysCapability.indexes ?? []).map((i: any) => [i.fields, i.unique ?? false])).toEqual([ - [['name'], 'organization'], - [['scope'], false], - [['active'], false], - [['package_id'], false], - ]); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts deleted file mode 100644 index f90c819c6d2..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.organization-unique.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; -import { SysPermissionSet } from './sys-permission-set.object.js'; - -/** - * #8554 — `sys_permission_set`'s declared uniqueness is organization-scoped. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured live on a real engine BEFORE the fix, driving - * this shipped declaration through `SqlDriver` under - * `OS_TENANCY_POSTURE=isolated`: - * - * ``` - * CREATE UNIQUE INDEX uniq_sys_permission_set_name - * on sys_permission_set (name) - * - * org_jia POST name=sales_readonly → 201 - * org_yi POST the SAME → 409 UNIQUE_VIOLATION - * org_yi POST an unused → 201 ← the control that makes it an ORACLE - * org_yi GET the key → total 0 ← refused by a row it cannot see - * ``` - * - * ## Why per-organization is the CORRECT boundary, not merely the safe one - * - * Permission sets are admin-authored: the object's own header says tenants may - * add custom rows created via UI / API while the schema itself is locked — the - * same sentence that made `sys_position` a defect (#8468). Two organizations - * naming a set `sales_readonly` are not in conflict. It is the third leg of the - * ADR-0090 RBAC triad, after `sys_capability` (#8461) and `sys_position` - * (#8556), and it sits in the same directory as both. - * - * The materialized shape, the anti-vacuity twin (a SAME-organization duplicate - * must still be refused), and the migration of an installation that already - * carries the old index are pinned driver-side in - * `driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts`. This test - * pins the declaration that suite's fixture copies. - */ -describe('sys_permission_set — declared uniqueness is organization-scoped (#8554)', () => { - const uniqueIndexes = (SysPermissionSet.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect((uniqueIndexes[0] as any).fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. Asserted by EQUALITY, never - // by truthiness: a truthy check accepts the very spelling that was the bug, - // which is how #8556's equivalent pin stayed green under its own ablation. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('matches the fixture the driver suite copies, entry for entry', () => { - // The driver suite hand-copies this declaration to keep the package - // boundary (the shape #8461 and #8556 used). This assertion catches ONE - // direction of drift: - // - // caught — the shipped declaration changes and the driver fixture - // does not. This test goes red. - // NOT caught — the DRIVER fixture is edited and this declaration is not. - // Nothing compares the two copies directly; they are only - // ever checked against this third spelling. - // - // Asserted on the BUILT value, which is what a driver is handed: - // `ObjectSchema.create` normalizes an authored `{ fields: [...] }` into - // `{ fields: [...], unique: false }`. A fixture copied from the source - // text alone would be subtly wrong about what the driver sees. - expect(SysPermissionSet.indexes).toEqual([ - { fields: ['name'], unique: 'organization' }, - { fields: ['active'], unique: false }, - { fields: ['package_id'], unique: false }, - ]); - }); - - it('takes no tenancy opt-out, so organization_id is injected (the scope has a column)', () => { - // Derived from the BUILT value, not a regex over source. If this ever goes - // false the `'organization'` spelling has no column to key on and the whole - // fix is silently inert — a failure the index assertions above cannot see. - const plan = resolveInjectedSystemColumns(SysPermissionSet); - expect(SysPermissionSet.tenancy).toBeUndefined(); - expect(plan.tenant).toBe(true); - expect(plan.names.has('organization_id')).toBe(true); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts deleted file mode 100644 index 1bdeb91f623..00000000000 --- a/packages/plugins/plugin-security/src/objects/sys-position.organization-unique.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; -import { SysPosition } from './sys-position.object'; - -/** - * #8468 — `sys_position.name` is unique per ORGANIZATION, not per installation. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so `name` was an installation-wide key on a - * tenant-scoped object. Measured live on a real engine before the fix, two - * organizations and the same name: - * - * ``` - * CREATE UNIQUE INDEX uniq_sys_position_name on sys_position (name) - * - * org_jia POST name=sales_manager → 201 - * org_yi POST the SAME name → 409 UNIQUE_VIOLATION - * org_yi POST an unused name → 201 - * org_yi GET that name → total 0 - * ``` - * - * A per-value refusal on a row the caller cannot read is a cross-tenant - * existence oracle; it was also a plain dead end, since the second organization - * could never name a position `sales_manager` and the 409 does not say why. - * - * ## Why per-organization is the CORRECT boundary, not merely the safe one - * - * Positions are admin-authored — `managed_by` defaults to `'admin'`, admins - * create them in Setup and via the `clone_position` action — so two - * organizations naming a position `sales_manager` are not in conflict. The - * maintainer ruling of 2026-08-13 settles the family this way. - * - * The hierarchy counter-argument weighed during triage does not apply to this - * object at all: positions are deliberately FLAT (ADR-0090 D3, finalizing - * ADR-0057 D5). `noPositionHierarchy` below pins that, because the argument for - * an installation-wide namespace would have to come from somewhere, and a - * `parent_id` appearing here later is exactly when someone would revisit it. - * - * Platform-seeded rows (`bootstrapBuiltinRoles`) carry no organization and the - * organization key part is NULL-safe (`COALESCE(organization_id,'__global__')`, - * ADR-0120 D3), so they stay unique among themselves. That behaviour, the - * materialized shape, and the migration of an installation that already carries - * the old index are pinned driver-side in - * `driver-sql/src/sql-driver-sys-position-organization-unique.test.ts`; this - * test pins the declaration that suite's fixture copies. - */ -describe('sys_position — declared uniqueness is organization-scoped (#8468)', () => { - const uniqueIndexes = (SysPosition.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (name)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect((uniqueIndexes[0] as any).fields).toEqual(['name']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. Asserted by equality, never - // by truthiness: a truthy check accepts the very spelling that was the bug. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['name'], - unique: 'organization', - }); - }); - - it('leaves the non-unique indexes alone', () => { - // Scope discipline: #8468 changes uniqueness scope, nothing else. - expect((SysPosition.indexes ?? []).map((i: any) => [i.fields, i.unique ?? false])).toEqual([ - [['name'], 'organization'], - [['active'], false], - ]); - }); - - it('matches the fixture the driver suite copies, entry for entry', () => { - // The driver suite hand-copies this declaration to keep the package - // boundary (the shape #8461 used). This assertion catches ONE direction of - // drift, and it is worth being exact about which: - // - // caught — the shipped declaration changes and the driver fixture does - // not. This test goes red. Measured: reverting the declaration - // to bare `true` leaves the entire driver suite green at 17/17, - // because that suite never imports `SysPosition`. This is the - // only thing standing between that edit and a silent pass. - // NOT caught — the DRIVER fixture is edited and this declaration is not. - // Nothing compares the literal below to `FIXED_APP`; the two - // copies are only ever checked against this third spelling. - // A driver-side edit drifts silently and this test cannot see - // it. Closing that direction means importing across the - // package boundary, which #8461 deliberately declined. - // - // So: keep both sides in step by hand, and treat the driver fixture as the - // copy that has no guard rather than assuming this pin covers it. - // - // Asserted on the BUILT value, which is what a driver is handed: - // `ObjectSchema.create` normalizes the authored `{ fields: ['active'] }` - // into `{ fields: ['active'], unique: false }`. The source spelling and the - // runtime spelling are not the same object, and a fixture copied from the - // source text alone would be subtly wrong about what the driver sees. - expect(SysPosition.indexes).toEqual([ - { fields: ['name'], unique: 'organization' }, - { fields: ['active'], unique: false }, - ]); - }); - - it('takes no tenancy opt-out, so organization_id is injected (the scope has a column)', () => { - // Derived from the built value, not a regex over source. If this ever goes - // false the `'organization'` spelling has no column to key on and the whole - // fix is inert — a silent failure the index assertions above cannot see. - const plan = resolveInjectedSystemColumns(SysPosition); - expect(SysPosition.tenancy).toBeUndefined(); - expect(plan.tenant).toBe(true); - expect(plan.names.has('organization_id')).toBe(true); - }); - - it('noPositionHierarchy: there is no parent_id, so no shared-namespace argument', () => { - // ADR-0090 D3 / ADR-0057 D5. The triage that produced the ruling treated a - // position hierarchy as the one candidate reason to want an - // installation-wide namespace; this object has none. - expect(Object.keys(SysPosition.fields)).not.toContain('parent_id'); - expect(Object.keys(SysPosition.fields)).not.toContain('parent'); - }); -}); diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index a45a8598be5..2590775ca7e 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -46,7 +46,6 @@ import { } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; @@ -59,11 +58,6 @@ interface Case { } const CASES: Case[] = [ - { - schema: SysPositionPermissionSet, - row: { position_id: 'pos_sales', permission_set_id: 'ps_crm_edit' }, - title: 'pos_sales → ps_crm_edit', - }, { schema: SysUserPermissionSet, // [ADR-0131 D4] A grant names its set; the title shows that name. diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 0cb481eb80c..f55aed9fe2e 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -51,9 +51,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index df359baec2f..5c91a12dd0e 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -77,7 +77,6 @@ import { describe, it, expect } from 'vitest'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { mapDataError, resolveThrownHttpError } from '@objectstack/types'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { classifyPackagedPermissionSet, PackagedPermissionSetLockedError, @@ -522,254 +521,10 @@ describe('pin 3 — the clone path yields an org-owned set with no upgrade linka }); }); -// ───────────────────────────────────────────────────────────────────────────── -// PIN 6 — what the CLONE ACTION SENDS: every copied facet, by identity (commit 5cb62d88b) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Pin 3 above drives the data door with a HAND-WRITTEN clone payload. That is - * precisely why it stayed green while this defect was live: it pins what the - * SERVER does with a payload, never what the ACTION DEFINITION chooses to put - * in one. `clone_permission_set` listed two of the row's six definition facets - * on its `params`, so cloning a set carrying system permissions, row-level - * security or tab permissions produced a clone with NONE of them — no error, a - * success toast, and the loss discoverable only by diffing the two records - * (commit 5cb62d88b). Fail-closed (fewer grants), and therefore quiet. - * - * It matters more since the ruling one commit above this one: the save door now - * refuses an in-place edit of a package-declared set AND its refusal names the - * clone path, so this action is the platform's own recommended remedy. - * - * So this suite reads the payload OUT OF THE ACTION instead of restating it. - * {@link clonePayload} is the clone dialog in miniature — the admin types the - * two inline params, every `defaultFromRow` param is seeded from the source row - * as objectui's `ActionParamDialog` seeds it, and `bodyExtra` rides along — so - * editing the params list is what moves this suite. That is the whole point: - * the class reopens the next time someone edits that list, unless a test is - * reading the list rather than a copy of it. - * - * ⭐ IDENTITIES, NOT COUNTS. "Five facets came across" holds constant while two - * of them swap, and asserting a facet is merely PRESENT passes on the empty - * default (`[]` / `{}`) that IS the bug. Every facet below is asserted against - * a NAMED, NON-EMPTY expected value. - */ - -/** The shipped `clone_permission_set` action — read, never restated. */ -const cloneAction = (): any => { - const action = (SysPermissionSet.actions ?? []).find((a: any) => a.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from SysPermissionSet.actions'); - return action; -}; - -/** - * The body the clone dialog POSTs to `/api/v1/data/sys_permission_set`, - * assembled from the ACTION DEFINITION the way the dialog assembles it: a - * `defaultFromRow` param is seeded from the source row under its resolved field - * name and submitted verbatim when the admin does not touch it; an inline param - * carries what the admin typed; `bodyExtra` is merged in. - */ -function clonePayload(row: any, typed: Record): Record { - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params ?? []) { - const key = p.field ?? p.name; - if (p.defaultFromRow) { - if (row[key] !== undefined) body[key] = row[key]; - } else if (key in typed) { - body[key] = typed[key]; - } - } - return body; -} - -/** - * The base an admin clones: EVERY definition facet populated, so no assertion - * below can be satisfied by the empty default the defect produced. - * - * ⚠️ The card's repro sketch named `member_default` as "a set whose - * `system_permissions` is non-empty". Measured on this tree that is not so — - * the platform `member_default` carries a large `rowLevelSecurity` and NO - * system permissions, and `showcase_member_default` carries neither (the D7 - * lint hard-blocks system permissions on an everyone-suggested set). The defect - * is real either way; the example was not. A fixture carrying all six facets at - * once is the shape that actually measures all three of the added ones, which a - * single real set would not. - */ -const richSet = (over: Record = {}) => ({ - name: 'ops_console', - label: 'Ops Console', - description: 'Runs the operations console.', - objects: { - showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - showcase_project: { allowRead: true }, - }, - fields: { 'showcase_project.budget': { readable: true, editable: false } }, - // The three facets dropped before commit 5cb62d88b, each non-empty and each named below. - systemPermissions: ['setup.access', 'ops.export_data'], - rowLevelSecurity: [ - { - name: 'ops_own_rows', - label: 'Own Tasks Only', - description: 'Operators only select tasks assigned to them.', - object: 'showcase_task', - operation: 'select' as const, - using: 'assignee == current_user.email', - positions: ['ops'], - enabled: true, - }, - ], - tabPermissions: { app_ops: 'default_on' as const, app_admin: 'hidden' as const }, - // The RULED exclusion's positive control — see the last test. - adminScope: { - businessUnit: 'field_ops', - includeSubtree: true, - manageAssignments: true, - manageBindings: false, - authorEnvironmentSets: false, - assignablePermissionSets: ['ops_console'], - }, - ...over, -}); - -/** - * The row the package door materializes for {@link richSet}. - * - * The return annotation is load-bearing, not decoration: without it tsc infers - * the object-literal type and DROPS the index signature that - * `permissionSetRowFields()` spreads in, so reading `.admin_scope` off the - * result — which the exclusion control below does directly, rather than through - * the `any[]` of `ql.permRows` — is a TS2339. It costs no precision: every - * facet column arrives through that spread already typed `any`. - */ -const richRow = (over: Record = {}): Record => ({ - id: 'ps_rich', - name: 'ops_console', - managed_by: 'package', - package_id: 'com.example.ops', - active: true, - ...permissionSetRowFields(richSet()), - ...over, -}); - -describe('pin 6 — the clone action SENDS every facet it copies, and says what it deliberately does not (#11703)', () => { - it('declares a defaultFromRow param for every copied facet — the identities, not a count', () => { - const carried = (cloneAction().params ?? []) - .filter((p: any) => p.defaultFromRow) - .map((p: any) => p.field ?? p.name) - .sort(); - - // ⭐ A COUNT here (`toHaveLength(6)`) would hold while `system_permissions` - // was swapped for something else. The set is spelled out. - expect(carried, 'the params list is what the dialog sends — these are the facets it carries').toEqual([ - 'description', - 'field_permissions', - 'object_permissions', - 'row_level_security', - 'system_permissions', - 'tab_permissions', - ]); - // `admin_scope` is the sixth definition column and is RULED out — pinned in - // its own test below so a future reader sees a decision, not an oversight. - expect(carried, 'admin_scope is excluded by ruling, not by accident').not.toContain('admin_scope'); - }); - - it('cloning a fully-populated set carries all five copied facets end to end, by name', async () => { - const ql = makeQl([richSet()]); - const protocol = makeHatchOpenProtocol(ql, { ops_console: richSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(richRow()); - const mw = makeMiddleware(ql, protocol); - - // The payload comes from the ACTION, not from this test. - const payload = clonePayload(ql.permRows[0], { - label: 'Ops Console (local)', - name: 'ops_console_local', - }); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, data: payload, - }); - - // (a) the DEFINITION that will be enforced — what `saveMetaItem` stored. - const body = protocol.saves.find((s: any) => s.name === 'ops_console_local')?.item; - expect(body, 'the clone was authored into the metadata store').toBeTruthy(); - expect(body.name).toBe('ops_console_local'); - expect(body.label, 'the admin-typed display name, not the base label').toBe('Ops Console (local)'); - expect(body.description).toBe(richSet().description); - expect(body.objects, 'object permissions').toEqual(richSet().objects); - expect(body.fields, 'field permissions').toEqual(richSet().fields); - // ⭐ The three dropped before commit 5cb62d88b. `[]` / `{}` here is the defect itself. - expect(body.systemPermissions, 'system permissions — [] here IS the #11703 silent drop').toEqual( - ['setup.access', 'ops.export_data'], - ); - expect( - (body.rowLevelSecurity ?? []).map((p: any) => p.name), - 'row-level security policies, by policy name', - ).toEqual(['ops_own_rows']); - expect(body.rowLevelSecurity, 'the RLS policy arrived whole, not as a name-only husk').toEqual( - richSet().rowLevelSecurity, - ); - expect(body.tabPermissions, 'tab permissions, per tab').toEqual({ - app_ops: 'default_on', app_admin: 'hidden', - }); - - // (b) the ROW the admin actually diffs the two records through. - const clone = ql.permRows.find((r: any) => r.name === 'ops_console_local'); - expect(clone, 'the clone record exists').toBeTruthy(); - expect(clone.managed_by, "this org's row").toBe('admin'); - expect(clone.package_id ?? null, 'no upgrade linkage').toBeNull(); - expect(JSON.parse(clone.system_permissions)).toEqual(['setup.access', 'ops.export_data']); - expect(JSON.parse(clone.row_level_security).map((p: any) => p.name)).toEqual(['ops_own_rows']); - expect(JSON.parse(clone.tab_permissions)).toEqual({ app_ops: 'default_on', app_admin: 'hidden' }); - - // The base is untouched by all of this (pin 3's invariant, re-checked on - // the richer shape — a payload that now carries five facets is a payload - // with five more chances to write to the wrong row). - const base = ql.permRows.find((r: any) => r.id === 'ps_rich'); - expect(JSON.parse(base.system_permissions), 'the package-declared base still declares its own').toEqual( - ['setup.access', 'ops.export_data'], - ); - expect(base.name).toBe('ops_console'); - }); - - it('CONTROL — admin_scope is deliberately NOT carried (ADR-0090 D12), and the dialog says so', async () => { - // ⭐ POSITIVE CONTROL FIRST. Without it "the clone has no admin_scope" is - // satisfied by a base that never had one, and this test would pin nothing. - const baseRow = richRow(); - expect(baseRow.admin_scope, 'the base carries a delegated-admin scope to lose').toBeTruthy(); - expect(JSON.parse(baseRow.admin_scope).businessUnit).toBe('field_ops'); - - // It is not on the wire… - const payload = clonePayload(baseRow, { label: 'Ops Console (local)', name: 'ops_console_local' }); - expect(Object.keys(payload), 'the clone dialog never sends admin_scope').not.toContain('admin_scope'); - - // …and it is not on the clone. - const ql = makeQl([richSet()]); - const protocol = makeHatchOpenProtocol(ql, { ops_console: richSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(baseRow); - const mw = makeMiddleware(ql, protocol); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, data: payload, - }); - - const body = protocol.saves.find((s: any) => s.name === 'ops_console_local')?.item; - expect(body, 'the clone was authored').toBeTruthy(); - expect(body.adminScope, 'a delegated-admin authority is a privilege decision, never a field copy').toBeUndefined(); - const clone = ql.permRows.find((r: any) => r.name === 'ops_console_local'); - expect(clone.admin_scope ?? null, 'and the column stays empty on the clone').toBeNull(); - - // ⭐ The exclusion has to READ as a decision to the admin standing in the - // dialog — otherwise it is the same silent drop commit 5cb62d88b fixed, merely - // ruled. The dialog's own explanatory line carries it. - const description = String(cloneAction().description ?? ''); - expect(description, 'the clone dialog states the exclusion').toMatch(/delegated-admin scope/i); - expect(description, 'and states that it is not copied').toMatch(/not copied/i); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 5 — fail-closed on ambiguity -// ───────────────────────────────────────────────────────────────────────────── +// [C3 stage 8] Pin 6 pinned the `clone_permission_set` action declared on the +// retired `sys_permission_set` object; the clone is the security catalog's +// `POST /security/permission-sets/:name/clone` door now, pinned beside it +// (`runtime/src/domains/permission-set-clone.test.ts`). describe('pin 5 — provenance that cannot be DETERMINED refuses the save', () => { it('a SchemaRegistry that throws is not the answer "not package-declared"', async () => { diff --git a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts index f03e9a2746e..ae2d77a7238 100644 --- a/packages/plugins/plugin-security/src/per-organization-catalog.test.ts +++ b/packages/plugins/plugin-security/src/per-organization-catalog.test.ts @@ -43,9 +43,9 @@ import { resolveUserAuthzGrants } from '@objectstack/core'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; diff --git a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts index 1cb4df84951..79503892e83 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts @@ -37,7 +37,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { permissionSetRowFields, reconcilePermissionSetProjection, diff --git a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts index 677ad6a9631..11d227bc66f 100644 --- a/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts +++ b/packages/plugins/plugin-security/src/plugin-keyed-text-bounds.test.ts @@ -68,23 +68,23 @@ describe('plugin-security suggestion key bounds are their referenced columns\' ( it('the suggestion key columns carry their referenced-column bounds, not just any bound', async () => { // Pinned by VALUE, and by the RELATION that sources each value: // package_id 255 — the width every landed same-class column - // uses, `sys_permission_set.package_id` - // included, which the SAME boot pass writes - // the SAME value into. - // permission_set_name 100 — the width of `sys_permission_set.name`, the - // column this value must resolve against at - // confirm time. + // uses. + // permission_set_name 100 — the width of the other column that names a + // permission set, a grant's + // `sys_user_permission_set.permission_set` + // (ADR-0131 D4): a name confirmed here is a + // name a grant stores. [C3 stage 8] It was + // `sys_permission_set.name`, a retired object. const byName = new Map((await registeredObjects()).map((o) => [o.name, o])); const suggestion = byName.get('sys_audience_binding_suggestion'); - const permissionSet = byName.get('sys_permission_set'); + const grant = byName.get('sys_user_permission_set'); expect(suggestion?.fields.package_id?.maxLength).toBe(255); expect(suggestion?.fields.permission_set_name?.maxLength).toBe(100); - // The referenced columns, read from the same registration surface — so a + // The referenced column, read from the same registration surface — so a // widening there turns THIS red instead of silently orphaning the bound. - expect(permissionSet?.fields.package_id?.maxLength).toBe(255); - expect(permissionSet?.fields.name?.maxLength).toBe(100); + expect(grant?.fields.permission_set?.maxLength).toBe(100); }); it('the composite key stays expressible on MySQL — utf8mb4 index-key ceiling', async () => { diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index d7451bb522c..477f53c71eb 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -33,10 +33,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { namesWithoutCatalogRow, positionNotInCatalogMessage } from './position-catalog-refusal.js'; diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts deleted file mode 100644 index 6de62903f68..00000000000 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ /dev/null @@ -1,889 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3, C2 stage S7] The `sys_position` data-door write-through under - * `single`, end to end inside one process: a real ObjectQL engine over the SQL - * driver, the real `SecurityPlugin` (so every write passes the security - * middleware the write-through is registered inside), and the REAL metadata - * door (`ObjectStackProtocolImplementation`, read from source by this package's - * vitest alias) over the same engine, writing real `sys_metadata` rows. - * - * What the pins hold, each against the door itself rather than a double of it: - * - * - a Setup create lands its row AND an environment definition that the - * security catalog read resolves; an assignment to it grants exactly what - * the same position granted as a row only; - * - an edit and a deactivation keep the row and the definition agreeing - * (`active` and `is_default` stay row-only); a rename moves the definition; - * a delete removes both; - * - a create, or a rename, into a name the metadata door refuses answers the - * door's own refusal and keeps nothing (seat re-rule Q1 = A); an edit of a - * row that already carries such a name still lands as a row write; - * - an edit that keeps a name a package or a built-in holds, and a delete of - * such a row, stand the write-through down: the write is passed on and - * nothing reaches metadata (Q2 = A), pinned at the write-through's own level - * rather than at the data door's answer; - * - a create, or a rename into, a name a package or a built-in holds answers - * the metadata door's own locked-base refusal and keeps nothing (C2 stage - * S10, ADR-0048 addendum N.2/N.3); the engine's own refusals keep theirs; - * - a package registering a name a Setup position holds in the environment - * ledger is refused `NAMESPACE_CONFLICT` (Q3 = A, within Q4 = A); - * - controls: a walled posture, a system write and a kernel without a metadata - * door write rows exactly as before. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { - SysMetadataObject, - SysMetadataHistoryObject, - SysMetadataCommitObject, - SysMetadataAuditObject, -} from '@objectstack/metadata-core'; -import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { mapDataError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; -import { DATA_MIGRATION_FLAG_OBJECT } from '@objectstack/spec/system'; -import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; -import { SysMigration } from '@objectstack/platform-objects/system'; - -import { SecurityPlugin } from './security-plugin.js'; -import { buildContextForUser } from './explain-engine.js'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; -import { createPositionWriteThrough } from './position-write-through.js'; -import { - POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID, - backfillRowOnlyPositions, - isRecordablePositionBackfillVerdict, - runOneTimePositionEnvironmentBackfill, -} from './position-environment-backfill.js'; - -const SYS = { isSystem: true } as const; -const POSTURE_ENV = 'OS_TENANCY_POSTURE'; -const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; -const ORG = 'org_pw'; -/** A package that declares one position, the way a stack's `positions` reach the registry. */ -const PKG = 'com.example.pw'; -const PKG_POSITION = 'pw_field_lead'; - -/** The non-system administrator whose writes are the data door's (superuser wildcard). */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - delete process.env[POSTURE_ENV]; - delete process.env[OWNER_ENV]; - resetPlatformAdminEmailMemo(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -interface Booted { - engine: ObjectQL; - protocol: any; - logger: { info: any; warn: any; error: any; debug: any }; - /** The data door's writer: the administrator, in the organization. */ - admin: Record; - catalogResolves(name: string): Promise; - envRows(name: string): Promise }>>; - rows(name: string): Promise; - /** Kernel lifecycle hooks the plugin registered, by event — collected, fired only by a test. */ - hooks: Map Promise>>; - catalog: ReturnType; -} - -async function boot(opts: { posture?: 'single' | 'isolated'; door?: boolean; ledger?: boolean } = {}): Promise { - const posture = opts.posture ?? 'single'; - const walled = posture !== 'single'; - if (walled) { - process.env[POSTURE_ENV] = posture; - process.env[OWNER_ENV] = 'admin@pw.example'; - } - resetPlatformAdminEmailMemo(); - - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.position-write-through', - name: 'Position write-through', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysUser, SysAccount, SysMember, SysOrganization, - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, - ...(opts.ledger === false ? [] : [SysMigration]), - ], - } as any); - await engine.syncSchemas(); - engines.push(engine); - - // A package-declared position: registered under its package, as the - // engine's stack collection loop registers a stack's `positions`, with the - // row the declared-position seeder writes for it (no provenance stamped). - (engine as any).registry.registerItem('position', { name: PKG_POSITION, label: 'Field lead' }, 'name', PKG); - await engine.insert('sys_position', { id: 'pos_pkg', name: PKG_POSITION, label: 'Field lead' }, { context: SYS } as any); - - const protocol = opts.door === false - ? null - : new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); - const metadata = { - get: async (type: string, name: string) => - type === 'permission' - ? ([...defaultPermissionSets, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async (type: string) => (type === 'position' ? [] : [...defaultPermissionSets, QA_ADMIN]), - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata, - ...(protocol ? { protocol } : {}), - ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } : {}), - }; - const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; - const hooks = new Map Promise>>(); - const ctx: any = { - logger, - hook: (event: string, handler: () => Promise) => { - if (!hooks.has(event)) hooks.set(event, []); - hooks.get(event)!.push(handler); - }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - await engine.insert('sys_organization', { id: ORG, name: 'PW Org', slug: 'pw' }, { context: SYS } as any); - const catalog = createSecurityCatalogReader({ registry: (engine as any).registry, metadata }); - return { - engine, - protocol, - logger, - hooks, - catalog, - admin: { userId: 'usr_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] }, - async catalogResolves(name) { - const entry = await catalog.resolve('position', name); - return entry !== undefined && entry.name === name; - }, - async envRows(name) { - const rows = (await engine.find('sys_metadata', { where: { type: 'position', name }, context: SYS })) as any[]; - return rows.map((r) => ({ - organization_id: r.organization_id ?? null, - state: r.state, - body: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata, - })); - }, - async rows(name) { - return (await engine.find('sys_position', { where: { name }, context: SYS })) as any[]; - }, - }; -} - - -/** The ADR-0112 envelope a refusal carries: its code and status. */ -const envelope = (e: any) => ({ code: e?.code, status: e?.status ?? e?.httpStatus }); -/** The refusal a write answered, or `null` when it was accepted. */ -const refusalOf = (p: Promise) => p.then(() => null, (e: unknown) => e); - -const create = (b: Booted, row: Record) => - b.engine.insert('sys_position', row, { context: b.admin } as any); -const patch = (b: Booted, id: string, data: Record) => - b.engine.update('sys_position', { id, ...data }, { context: b.admin } as any); -const remove = (b: Booted, id: string) => - b.engine.delete('sys_position', { where: { id }, context: b.admin } as any); - -/** What the explain engine answers for one principal: the grants a position carries. */ -async function grantsOf(engine: ObjectQL, userId: string): Promise> { - const ctx = await buildContextForUser(engine, userId, Date.now(), ORG); - return { - positions: [...(ctx.positions ?? [])].sort(), - permissions: [...(ctx.permissions ?? [])].sort(), - systemPermissions: [...(ctx.systemPermissions ?? [])].sort(), - }; -} - -describe('[ADR-0131 D3] position write-through under single — a Setup position gets its environment definition', () => { - it('a Setup create lands its row AND an environment definition that the security catalog read resolves', async () => { - const b = await boot(); - await create(b, { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', is_default: true }); - const [row] = await b.rows('regional_lead'); - expect({ label: row.label, is_default: !!row.is_default, active: !!row.active }).toEqual({ - label: 'Regional lead', is_default: true, active: true, - }); - // `is_default` and `active` are row state: the definition carries neither. - expect(await b.envRows('regional_lead')).toEqual([{ - organization_id: null, - state: 'active', - body: { name: 'regional_lead', label: 'Regional lead', description: 'Leads a region', delegatable: false }, - }]); - expect(await b.catalogResolves('regional_lead')).toBe(true); - }); - - // [ADR-0131 D3/D4] The resolver reads a position's sets from its definition's - // `permissionSets`, never from the junction: the binding Setup makes as a - // `sys_position_permission_set` row grants nothing, with the write-through or - // without it. Setup moves onto the definition with C9 and the write-through's - // retirement; until then this is the measured gap the release note names. - it('a user assigned to a Setup-created position is granted the same with or without the write-through — and a junction binding grants nothing', async () => { - const grantsThrough = async (door: boolean) => { - const b = await boot({ door }); - await b.engine.insert('sys_user', { - id: 'usr_holder', email: 'holder@pw.example', name: 'holder', email_verified: true, - }, { context: SYS } as any); - await b.engine.insert('sys_member', { - id: 'm_holder', user_id: 'usr_holder', organization_id: ORG, role: 'member', - }, { context: SYS } as any); - await b.engine.insert('sys_permission_set', { - id: 'ps_pw_reports', - name: 'pw_reports', - label: 'Reports', - object_permissions: JSON.stringify({ sys_position: { allowRead: true } }), - system_permissions: JSON.stringify(['view_reports']), - }, { context: SYS } as any); - const created: any = await create(b, { name: 'report_reader', label: 'Report reader' }); - await b.engine.insert('sys_position_permission_set', { - id: 'pps_reports', position_id: created.id, permission_set_id: 'ps_pw_reports', - }, { context: SYS } as any); - await b.engine.insert('sys_user_position', { - id: 'up_holder', user_id: 'usr_holder', position: 'report_reader', organization_id: ORG, - }, { context: SYS } as any); - return { grants: await grantsOf(b.engine, 'usr_holder'), defined: (await b.envRows('report_reader')).length }; - }; - const before = await grantsThrough(false); // no metadata door: the row-only position, as before this stage - const after = await grantsThrough(true); - expect(before.defined).toBe(0); - expect(after.defined).toBe(1); - expect(after.grants).toEqual(before.grants); - expect(after.grants).toMatchObject({ positions: expect.arrayContaining(['report_reader']) }); - expect((after.grants as any).permissions).not.toContain('pw_reports'); - }); - - // [ADR-0131 D3/D4] `permissionSets` has no row column: a Setup edit of the - // row saves the definition again, and must carry the binding the metadata - // door declared rather than clear it. - it('a row edit keeps the `permissionSets` the stored definition names, and a rename carries them to the new name', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); - await (b.protocol as any).saveMetaItem({ - type: 'position', name: 'night_lead', item: { name: 'night_lead', label: 'Night lead', permissionSets: ['night_tools'] }, - }); - expect((await b.envRows('night_lead')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); - await patch(b, created.id, { label: 'Night supervisor' }); - expect((await b.envRows('night_lead')).map((r) => r.body)).toEqual([ - { name: 'night_lead', label: 'Night supervisor', delegatable: false, permissionSets: ['night_tools'] }, - ]); - await patch(b, created.id, { name: 'night_supervisor' }); - expect((await b.envRows('night_supervisor')).map((r) => r.body.permissionSets)).toEqual([['night_tools']]); - }); - - it('an edit keeps the row and the definition agreeing; a deactivation and is_default stay on the row', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'shift_lead', label: 'Shift lead' }); - await patch(b, created.id, { label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, - ]); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await patch(b, created.id, { active: false }); - await patch(b, created.id, { is_default: true }); - expect(saves, 'a row-state patch writes no definition').not.toHaveBeenCalled(); - const [row] = await b.rows('shift_lead'); - expect({ label: row.label, active: !!row.active, is_default: !!row.is_default }).toEqual({ - label: 'Shift supervisor', active: false, is_default: true, - }); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift supervisor', description: 'Runs a shift', delegatable: true }, - ]); - }); - - it('a rename saves the new name\'s definition and deletes the old one', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'night_lead', label: 'Night lead' }); - await patch(b, created.id, { name: 'evening_lead' }); - expect(await b.rows('night_lead')).toEqual([]); - expect((await b.rows('evening_lead')).map((r) => r.id)).toEqual([created.id]); - expect(await b.envRows('night_lead')).toEqual([]); - expect((await b.envRows('evening_lead')).map((r) => r.body)).toEqual([ - { name: 'evening_lead', label: 'Night lead', delegatable: false }, - ]); - expect(await b.catalogResolves('night_lead')).toBe(false); - expect(await b.catalogResolves('evening_lead')).toBe(true); - }); - - it('a delete removes the row, then the definition', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'temp_lead', label: 'Temp lead' }); - expect(await b.catalogResolves('temp_lead')).toBe(true); - await remove(b, created.id); - expect(await b.rows('temp_lead')).toEqual([]); - expect(await b.envRows('temp_lead')).toEqual([]); - expect(await b.catalogResolves('temp_lead')).toBe(false); - }); - - it('a definition delete that fails is reported at error, once, naming the remedy — the row stays deleted', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'sticky_lead', label: 'Sticky lead' }); - vi.spyOn(b.protocol, 'deleteMetaItem').mockRejectedValueOnce(new Error('store unavailable')); - await remove(b, created.id); - expect(await b.rows('sticky_lead')).toEqual([]); - const errors = b.logger.error.mock.calls.filter(([m]: [string]) => String(m).includes("'sticky_lead'")); - expect(errors).toHaveLength(1); - expect(String(errors[0][0])).toContain('DELETE /api/v1/meta/position/sticky_lead'); - }); -}); - -describe('[ADR-0131 D3] Q1 = A — a name the metadata door refuses is refused with the door\'s own answer', () => { - it.each([ - ['uppercase and a space', 'Regional Lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a hyphen', 'regional-lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a leading digit', '9lead', { code: 'INVALID_REQUEST', status: 400 }], - ['a leading underscore', '_lead', { code: 'INVALID_REQUEST', status: 400 }], - ['one character', 'r', { code: 'INVALID_METADATA', status: 422 }], - ['a dot', 'regional.lead', { code: 'INVALID_METADATA', status: 422 }], - ])('a create named with %s is refused and keeps nothing', async (_label, name, expected) => { - const b = await boot(); - const refusal = await refusalOf(create(b, { name, label: 'x' })); - expect(envelope(refusal)).toEqual(expected); - expect(await b.rows(name), 'the row write was undone').toEqual([]); - expect(await b.envRows(name)).toEqual([]); - }); - - it('a rename into such a name is refused, and the row and its definition keep the old name', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'audit_lead', label: 'Audit lead' }); - const refusal = await refusalOf(patch(b, created.id, { name: 'Audit Lead', label: 'Renamed' })); - expect(envelope(refusal)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); - const [row] = await b.rows('audit_lead'); - expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Audit lead' }); - expect(await b.rows('Audit Lead')).toEqual([]); - expect((await b.envRows('audit_lead')).map((r) => r.body)).toEqual([ - { name: 'audit_lead', label: 'Audit lead', delegatable: false }, - ]); - }); - - it('control: an edit of a row that already carries such a name lands as a row write, with no definition', async () => { - const b = await boot(); - // A row written before this stage (a system write is never translated). - await b.engine.insert('sys_position', { id: 'pos_legacy', name: 'Legacy Lead', label: 'Legacy' }, { context: SYS } as any); - await patch(b, 'pos_legacy', { label: 'Legacy (renamed label)' }); - const [row] = await b.rows('Legacy Lead'); - expect(row?.label).toBe('Legacy (renamed label)'); - expect(await b.envRows('Legacy Lead')).toEqual([]); - }); - - it('a metadata refusal of a legal name undoes the create, and the refusal is the answer', async () => { - const b = await boot(); - const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); - const refusal = await refusalOf(create(b, { name: 'store_lead', label: 'Store lead' })); - expect(refusal).toBe(refused); - expect(await b.rows('store_lead')).toEqual([]); - expect(await b.envRows('store_lead')).toEqual([]); - }); - - it('a metadata refusal of an edit restores the row the edit changed', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'desk_lead', label: 'Desk lead' }); - const refused = Object.assign(new Error('store refused'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(refused); - expect(await refusalOf(patch(b, created.id, { label: 'Desk supervisor' }))).toBe(refused); - expect((await b.rows('desk_lead'))[0]?.label).toBe('Desk lead'); - expect((await b.envRows('desk_lead')).map((r) => r.body)).toEqual([ - { name: 'desk_lead', label: 'Desk lead', delegatable: false }, - ]); - }); -}); - -describe('[ADR-0131 D3] Q2 = A — an edit that keeps, or a delete of, a name a package or a built-in holds stands the write-through down', () => { - // Pinned at the write-through's own level, never at the data door's answer: - // whether the data door admits an edit of a package-declared row is the - // system-row gate's call, decided on the row's provenance stamp, and that - // stamp is another change's. The write-through must stand down whatever the - // stamp says, because it reads the name's holder from the engine registry. - // A CREATE under such a name is no stand-down since stage S10 (below). - it('the write-through passes an edit or a delete on a package-held name to the next step, and writes nothing to metadata', async () => { - const b = await boot(); - const door = { - saveMetaItem: vi.fn(async (_request: Record) => undefined), - deleteMetaItem: vi.fn(async (_request: Record) => undefined), - // The real door's verdict, so the S10 refusal is in force for this double too. - packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'create' | 'delete' }) => - b.protocol.packagedBaseRefusal(request) as Error | null), - }; - const writeThrough = createPositionWriteThrough({ - ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, - }); - const passOn = async (opCtx: any, write: () => Promise) => { - const next = vi.fn(async () => { opCtx.result = await write(); }); - await writeThrough(opCtx, next); - return next.mock.calls.length; - }; - - // An edit of the package-declared row (the label and delegatable a Setup edit sends). - const edit = { object: 'sys_position', operation: 'update', data: { id: 'pos_pkg', label: 'Edited', delegatable: true }, context: b.admin }; - expect(await passOn(edit, () => b.engine.update('sys_position', { id: 'pos_pkg', label: 'Edited', delegatable: true }, { context: SYS } as any))) - .toBe(1); - // A delete of the package-declared row. - const del = { object: 'sys_position', operation: 'delete', options: { where: { id: 'pos_pkg' } }, context: b.admin }; - expect(await passOn(del, () => b.engine.delete('sys_position', { where: { id: 'pos_pkg' }, context: SYS } as any))).toBe(1); - - expect(door.saveMetaItem).not.toHaveBeenCalled(); - expect(door.deleteMetaItem).not.toHaveBeenCalled(); - expect(await b.envRows(PKG_POSITION)).toEqual([]); - - // Control: the same write-through, the same door, a name only Setup holds — it writes the definition. - const setupOnly = { object: 'sys_position', operation: 'insert', data: { name: 'setup_lead', label: 'Setup lead' }, context: b.admin }; - expect(await passOn(setupOnly, () => b.engine.insert('sys_position', { name: 'setup_lead', label: 'Setup lead' }, { context: SYS } as any))) - .toBe(1); - expect(door.saveMetaItem).toHaveBeenCalledTimes(1); - expect(door.saveMetaItem.mock.calls[0][0]).toMatchObject({ type: 'position', name: 'setup_lead' }); - }); - - it('control: the metadata door itself refuses an environment save over that name (NOT_OVERRIDABLE)', async () => { - const b = await boot(); - const refusal = await refusalOf( - b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'x' } }), - ); - expect(envelope(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - }); -}); - -describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create or a rename into a held name answers the door\'s refusal', () => { - /** The metadata door's own envelope for an environment save over a name a package or a built-in holds. */ - const DOOR_REFUSAL = { code: 'NOT_OVERRIDABLE', status: 403 }; - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares as an audience anchor', 'everyone'], - ['the platform declares as an audience anchor (guest)', 'guest'], - ])('a create under a name %s is refused with the door\'s envelope, and keeps no row and no definition', async (_holder, name) => { - const b = await boot(); - const before = (await b.rows(name)).map((r) => r.id); - const refusal = await refusalOf(create(b, { name, label: 'Mine' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect((await b.rows(name)).map((r) => r.id), 'the created row was undone').toEqual(before); - expect(await b.envRows(name)).toEqual([]); - }); - - it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata CREATE gets', async () => { - // [#22591] A Setup create is a create, so it is the door's answer to a - // create that is relayed: the metadata door's first-write save - // (`parentVersion: null`, `If-None-Match: *` on its REST route). - const b = await boot(); - const refusal: any = await refusalOf(create(b, { name: PKG_POSITION, label: 'Mine' })); - const door: any = await refusalOf(b.protocol.saveMetaItem({ - type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' }, parentVersion: null, - })); - expect({ code: refusal?.code, status: refusal?.status, message: refusal?.message }) - .toEqual({ code: door?.code, status: door?.status, message: door?.message }); - }); - - // [#22591] The remedy follows the act. The administrator is creating a - // position of their own (or renaming one into the name), so the refusal names - // the remedy a create has — a name no package or built-in holds, and where the - // held names are listed — never the edit remedy ("edit the source artifact and - // redeploy") of a position someone else declared. Pinned on the KIND: what the - // sentence points at, not its words. The door's edit refusal is the control. - const LISTING = 'GET /api/v1/meta/position'; - const remedyKind = (message: unknown) => { - const text = String(message); - const newName = text.includes(LISTING); - const edit = /\bredeploy\b|\bsource\b/.test(text); - return newName && edit ? 'both' : newName ? 'new-name' : edit ? 'edit' : 'none'; - }; - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares (everyone)', 'everyone'], - ])('a create under a name %s names a free name, not the edit remedy; the door\'s edit refusal keeps its own', async (_holder, name) => { - const b = await boot(); - const refusal: any = await refusalOf(create(b, { name, label: 'Mine' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(remedyKind(refusal?.message)).toBe('new-name'); - const edit: any = await refusalOf(b.protocol.saveMetaItem({ type: 'position', name, item: { name, label: 'Mine' } })); - expect(envelope(edit)).toEqual(DOOR_REFUSAL); - expect(remedyKind(edit?.message)).toBe('edit'); - }); - - it('a rename into a held name names a free name too', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'lane_lead', label: 'Lane lead' }); - const refusal: any = await refusalOf(patch(b, created.id, { name: 'guest' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(remedyKind(refusal?.message)).toBe('new-name'); - }); - - it('a bulk create carrying one held name is refused whole: no row of the write is kept', async () => { - const b = await boot(); - const refusal = await refusalOf(b.engine.insert('sys_position', [ - { name: 'bulk_lead', label: 'Bulk lead' }, - { name: PKG_POSITION, label: 'Mine' }, - ], { context: b.admin } as any)); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - expect(await b.rows('bulk_lead')).toEqual([]); - expect(await b.envRows('bulk_lead')).toEqual([]); - expect((await b.rows(PKG_POSITION)).map((r) => r.id)).toEqual(['pos_pkg']); - }); - - it.each([ - ['a package declares', PKG_POSITION], - ['the platform declares', 'guest'], - ])('a rename into a name %s is refused; the row and its definition keep the old name', async (_holder, held) => { - const b = await boot(); - const created: any = await create(b, { name: 'route_lead', label: 'Route lead' }); - const refusal = await refusalOf(patch(b, created.id, { name: held, label: 'Renamed' })); - expect(envelope(refusal)).toEqual(DOOR_REFUSAL); - const [row] = await b.rows('route_lead'); - expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Route lead' }); - expect((await b.rows(held)).map((r) => r.id)).not.toContain(created.id); - // Before S10 the rename stood down for the new name and then deleted the old name's definition. - expect((await b.envRows('route_lead')).map((r) => r.body)).toEqual([ - { name: 'route_lead', label: 'Route lead', delegatable: false }, - ]); - expect(await b.envRows(held)).toEqual([]); - }); - - it('a verdict the door cannot reach is no admission: the create and the rename are undone, and the failure is the answer', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'yard_lead', label: 'Yard lead' }); - const unreadable = Object.assign(new Error('registry unreadable'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn(b.protocol, 'packagedBaseRefusal').mockImplementation(() => { throw unreadable; }); - expect(await refusalOf(create(b, { name: 'dock_lead', label: 'Dock lead' }))).toBe(unreadable); - expect(await b.rows('dock_lead')).toEqual([]); - expect(await refusalOf(patch(b, created.id, { name: 'gate_lead' }))).toBe(unreadable); - expect((await b.rows('yard_lead')).map((r) => r.id)).toEqual([created.id]); - expect(await b.rows('gate_lead')).toEqual([]); - }); - - it('control: the engine\'s own refusals keep their answer — a reserved identity name stays VALIDATION_FAILED', async () => { - const b = await boot(); - // The platform holds `org_admin` too, but the engine's rule validator refuses - // the row before the door is asked (the REST layer answers it 400). - const refusal: any = await refusalOf(create(b, { name: 'org_admin', label: 'Mine' })); - expect({ name: refusal?.name, code: refusal?.code }).toEqual({ name: 'ValidationError', code: 'VALIDATION_FAILED' }); - expect(await b.rows('org_admin')).toEqual([]); - }); - - it('control: an edit that keeps a held name stays a row write, with no definition and no refusal', async () => { - const b = await boot(); - // A row that predates this stage: an organization's own row under the package's name. - await b.engine.insert('sys_position', { - id: 'pos_twin', name: PKG_POSITION, label: 'Twin', organization_id: ORG, - }, { context: SYS } as any); - await patch(b, 'pos_twin', { label: 'Twin (relabelled)' }); - expect((await b.rows(PKG_POSITION)).find((r) => r.id === 'pos_twin')?.label).toBe('Twin (relabelled)'); - expect(await b.envRows(PKG_POSITION)).toEqual([]); - }); - - it('control: a door that brings no locked-base verdict keeps the stand-down', async () => { - const b = await boot(); - const door = { - saveMetaItem: vi.fn(async (_request: Record) => undefined), - deleteMetaItem: vi.fn(async (_request: Record) => undefined), - }; - const writeThrough = createPositionWriteThrough({ - ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger, - }); - const opCtx: any = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin }; - const next = vi.fn(async () => { opCtx.result = { id: 'pos_builtin', name: 'everyone' }; }); - await writeThrough(opCtx, next); - expect(next).toHaveBeenCalledTimes(1); - expect(door.saveMetaItem).not.toHaveBeenCalled(); - }); -}); - -describe('[ruling Q (a)] one holder per position name per deployment — a create or a rename into a name the environment catalog holds is refused', () => { - /** - * The wire envelope the REST door answers (`mapDataError`) — the one a - * second row of a name in ONE organization already answers, pinned as the - * control below. - */ - const DUPLICATE = { code: 'UNIQUE_VIOLATION', status: 409 }; - const httpEnvelope = (e: unknown) => { - const r = mapDataError(e, 'sys_position'); - return { code: (r.body as { code?: unknown }).code, status: r.status }; - }; - const OTHER_ORG = 'org_pw_other'; - /** An administrator of a SECOND organization of the same `single` deployment. */ - const otherAdmin = (b: Booted) => ({ ...b.admin, tenantId: OTHER_ORG, accessible_org_ids: [OTHER_ORG] }); - const bootTwo = async () => { - const b = await boot(); - await b.engine.insert('sys_organization', { id: OTHER_ORG, name: 'PW Other', slug: 'pw-other' }, { context: SYS } as any); - return b; - }; - - it("a second organization's create of a name the first organization's position holds is refused 409; the first definition is untouched", async () => { - const b = await bootTwo(); - await create(b, { name: 'shift_lead', label: 'Shift lead', description: 'First holder' }); - const refusal = await refusalOf(b.engine.insert('sys_position', { name: 'shift_lead', label: 'Theirs' }, { context: otherAdmin(b) } as any)); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - // One dialect: a second holder in the SAME organization answers the same. - expect(httpEnvelope(await refusalOf(create(b, { name: 'shift_lead', label: 'Again' })))).toEqual(DUPLICATE); - expect((await b.rows('shift_lead')).map((r) => r.label)).toEqual(['Shift lead']); - expect((await b.envRows('shift_lead')).map((r) => r.body)).toEqual([ - { name: 'shift_lead', label: 'Shift lead', description: 'First holder', delegatable: false }, - ]); - }); - - it("a create of a name saved only through the metadata door is refused 409; the definition keeps its permissionSets", async () => { - const b = await boot(); - await b.protocol.saveMetaItem({ - type: 'position', name: 'door_lead', item: { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, - }); - const refusal = await refusalOf(create(b, { name: 'door_lead', label: 'Setup copy' })); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - expect(await b.rows('door_lead')).toEqual([]); - expect((await b.envRows('door_lead')).map((r) => r.body)).toEqual([ - { name: 'door_lead', label: 'Door lead', permissionSets: ['viewer_readonly'] }, - ]); - }); - - it('a rename into a name the environment catalog holds is refused 409; the row keeps its old name', async () => { - const b = await bootTwo(); - await create(b, { name: 'dock_chief', label: 'Dock chief' }); - const mine: any = await b.engine.insert('sys_position', { name: 'yard_chief', label: 'Yard chief' }, { context: otherAdmin(b) } as any); - const refusal = await refusalOf(b.engine.update('sys_position', { id: mine.id, name: 'dock_chief' }, { context: otherAdmin(b) } as any)); - expect(httpEnvelope(refusal)).toEqual(DUPLICATE); - expect((await b.rows('yard_chief')).map((r) => r.id)).toEqual([mine.id]); - expect((await b.envRows('dock_chief')).map((r) => r.body.label)).toEqual(['Dock chief']); - }); - - it('control: a name no definition holds is created, and an edit that keeps a held name is no second holder', async () => { - const b = await boot(); - const created: any = await create(b, { name: 'free_lead', label: 'Free lead' }); - await patch(b, created.id, { label: 'Free lead (relabelled)' }); - expect((await b.envRows('free_lead')).map((r) => r.body.label)).toEqual(['Free lead (relabelled)']); - }); - - it('a catalog that cannot be read refuses the create — accepting would merge blind', async () => { - const b = await boot(); - const unreadable = Object.assign(new Error('catalog unreadable'), { code: 'AUTHZ_STORE_UNAVAILABLE', status: 503 }); - vi.spyOn((b.engine as any).registry, 'getItem').mockImplementation(() => { throw unreadable; }); - const refusal: any = await refusalOf(create(b, { name: 'blind_lead', label: 'Blind lead' })); - expect(refusal).not.toBeNull(); - vi.restoreAllMocks(); - expect(await b.rows('blind_lead')).toEqual([]); - }); -}); - -describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => { - it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => { - const b = await boot(); - await create(b, { name: 'claims_lead', label: 'Claims lead' }); - let thrown: any = null; - try { - (b.engine as any).registry.registerItem('position', { name: 'claims_lead', label: 'Pkg' }, 'name', 'com.example.other'); - } catch (e) { thrown = e; } - expect(envelope(thrown)).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); - expect(thrown?.existingHolder).toEqual({ kind: 'environment' }); - expect(String(thrown?.message)).toContain('com.example.other'); - }); -}); - -describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it was', () => { - it('a walled posture: an organization administrator\'s create lands its organization row, and no definition', async () => { - const b = await boot({ posture: 'isolated' }); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await create(b, { name: 'plant_lead', label: 'Plant lead' }); - expect((await b.rows('plant_lead')).map((r) => r.organization_id)).toEqual([ORG]); - expect(saves).not.toHaveBeenCalled(); - expect(await b.envRows('plant_lead')).toEqual([]); - }); - - // [ADR-0131 D3, #15196 Q3 = A] The promotion path the release note names: - // under a wall an organization's Setup position is a row with no definition, - // so it grants nothing; the metadata door takes an environment definition - // under the same name, and that definition is what the catalog then serves. - it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { - const b = await boot({ posture: 'isolated' }); - await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); - expect(await b.catalogResolves('plant_auditor')).toBe(false); - await (b.protocol as any).saveMetaItem({ - type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, - }); - expect((await b.envRows('plant_auditor')).map((r) => r.body.permissionSets)).toEqual([['plant_tools']]); - expect(await b.catalogResolves('plant_auditor')).toBe(true); - }); - - it('a system write is never translated', async () => { - const b = await boot(); - await b.engine.insert('sys_position', { id: 'pos_sys', name: 'seeded_lead', label: 'Seeded' }, { context: SYS } as any); - expect((await b.rows('seeded_lead')).length).toBe(1); - expect(await b.envRows('seeded_lead')).toEqual([]); - }); - - it('a kernel with no metadata door writes the row exactly as before', async () => { - const b = await boot({ door: false }); - await create(b, { name: 'plain_lead', label: 'Plain lead' }); - expect((await b.rows('plain_lead')).length).toBe(1); - expect(await b.envRows('plain_lead')).toEqual([]); - }); - - it('a built-in position is still refused at the admin door, before anything is translated', async () => { - const b = await boot(); - await b.engine.insert('sys_position', { - id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', - }, { context: SYS } as any); - const refusal: any = await refusalOf(patch(b, 'pos_everyone', { label: 'All' })); - expect(refusal?.name).toBe('PermissionDeniedError'); - expect(await b.envRows('everyone')).toEqual([]); - }); - - it('the permission-set write-through is unchanged: a data-door set create still becomes a permission definition', async () => { - const b = await boot(); - await b.engine.insert('sys_permission_set', { name: 'pw_viewers', label: 'Viewers' }, { context: b.admin } as any); - const rows = (await b.engine.find('sys_metadata', { where: { type: 'permission', name: 'pw_viewers' }, context: SYS })) as any[]; - expect(rows.map((r) => r.organization_id ?? null)).toEqual([null]); - expect(await b.envRows('pw_viewers')).toEqual([]); - }); -}); - -describe('[ADR-0131 D3] the row-only position backfill — once, under single, remembered in sys_migration', () => { - /** Rows written before the write-through existed: system writes, as the pre-stage data door left them. */ - const seedRowOnly = async (b: Booted) => { - await b.engine.insert('sys_position', [ - { id: 'pos_a', name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true }, - { id: 'pos_b', name: 'Legacy Lead', label: 'Illegal name' }, - { id: 'pos_c', name: 'twin_lead', label: 'Twin' }, - { id: 'pos_d', name: 'twin_lead', label: 'Twin', organization_id: ORG }, - ], { context: SYS } as any); - }; - const census = async (b: Booted) => { - const names = [...new Set(((await b.engine.find('sys_position', { context: SYS })) as any[]).map((r) => r.name))]; - const rowOnly: string[] = []; - for (const n of names) if (!(await b.catalogResolves(n))) rowOnly.push(n); - return rowOnly.sort(); - }; - const runBootstrapped = async (b: Booted) => { - for (const handler of b.hooks.get('kernel:bootstrapped') ?? []) await handler(); - }; - const ledgerRows = async (b: Booted) => - (await b.engine.find(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: POSITION_ENVIRONMENT_BACKFILL_MIGRATION_ID }, context: SYS, - })) as any[]; - - it('SecurityPlugin runs it at kernel:bootstrapped: the row-only census is empty after it, save the refused-name class', async () => { - const b = await boot(); - await seedRowOnly(b); - expect(await census(b)).toEqual(['Legacy Lead', 'legacy_lead', 'twin_lead']); - await runBootstrapped(b); - expect(await census(b), 'only the final refused-name class stays row-only').toEqual(['Legacy Lead']); - expect((await b.envRows('legacy_lead')).map((r) => ({ org: r.organization_id, body: r.body }))).toEqual([ - { org: null, body: { name: 'legacy_lead', label: 'Legacy lead', description: 'Before S7', delegatable: true } }, - ]); - expect(await b.envRows(PKG_POSITION), 'a package-declared position is left alone').toEqual([]); - const [ledger] = await ledgerRows(b); - expect(JSON.parse(ledger.details)).toEqual({ names: 4, rowOnly: 3, backfilled: 2, refusedName: 1 }); - const warned = b.logger.warn.mock.calls.filter(([m]: [string]) => String(m).includes('keep no environment definition')); - expect(warned).toHaveLength(1); - expect(warned[0][1]).toEqual({ count: 1, positions: { names: ['Legacy Lead'] } }); - }); - - it('a rerun changes nothing: the recorded verdict skips the pass, and a fresh pass writes nothing', async () => { - const b = await boot(); - await seedRowOnly(b); - await runBootstrapped(b); - const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - const again = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(again.status).toBe('already-run'); - const pass = await backfillRowOnlyPositions(b.engine as any, { catalog: b.catalog, door: b.protocol, logger: b.logger }); - expect({ rowOnly: pass.rowOnly, backfilled: pass.backfilled, refusedName: pass.refusedName }).toEqual({ - rowOnly: ['Legacy Lead'], backfilled: [], refusedName: ['Legacy Lead'], - }); - expect(saves).not.toHaveBeenCalled(); - expect(await ledgerRows(b)).toHaveLength(1); - }); - - it('rows of one name that disagree are not guessed at: reported at error, and the verdict stays unrecorded', async () => { - const b = await boot(); - await b.engine.insert('sys_position', [ - { id: 'pos_x', name: 'split_lead', label: 'Split A' }, - { id: 'pos_y', name: 'split_lead', label: 'Split B', organization_id: ORG }, - ], { context: SYS } as any); - const out = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(out.status).toBe('undecided'); - expect(out.backfill?.conflicting).toEqual(['split_lead']); - expect(await b.envRows('split_lead')).toEqual([]); - expect(await ledgerRows(b)).toEqual([]); - expect(b.logger.error.mock.calls.some(([m]: [string]) => String(m).includes('rows carrying each name disagree'))).toBe(true); - }); - - it('a definition write that does not land is loud, and the next boot retries', async () => { - const b = await boot(); - await seedRowOnly(b); - vi.spyOn(b.protocol, 'saveMetaItem').mockRejectedValueOnce(new Error('store unavailable')); - const first = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(first.status).toBe('undecided'); - expect(first.backfill?.failed).toEqual(['legacy_lead']); - expect(await ledgerRows(b)).toEqual([]); - expect(b.logger.error).toHaveBeenCalled(); - const second = await runOneTimePositionEnvironmentBackfill(b.engine as any, { - posture: 'single', catalog: b.catalog, door: b.protocol, logger: b.logger, - }); - expect(second.status).toBe('ran'); - expect(second.backfill?.backfilled).toEqual(['legacy_lead']); - expect(await ledgerRows(b)).toHaveLength(1); - }); - - it('a walled posture does not run it; neither does a kernel with no metadata door record a verdict', async () => { - const walled = await boot({ posture: 'isolated' }); - await seedRowOnly(walled); - expect((await runOneTimePositionEnvironmentBackfill(walled.engine as any, { - posture: 'isolated', catalog: walled.catalog, door: walled.protocol, logger: walled.logger, - })).status).toBe('not-applicable'); - expect(await walled.envRows('legacy_lead')).toEqual([]); - - const doorless = await boot({ door: false }); - await seedRowOnly(doorless); - const out = await runOneTimePositionEnvironmentBackfill(doorless.engine as any, { - posture: 'single', catalog: doorless.catalog, door: null, logger: doorless.logger, - }); - expect({ status: out.status, stopped: out.backfill?.stopped }).toEqual({ status: 'undecided', stopped: 'door-absent' }); - expect(await ledgerRows(doorless)).toEqual([]); - }); - - it('isRecordablePositionBackfillVerdict: the refused-name class is final; a failure, a conflict or a stop is not', () => { - const base = { names: 1, rowOnly: ['x'], backfilled: [], refusedName: ['X X'], conflicting: [], failed: [] }; - expect(isRecordablePositionBackfillVerdict(base)).toBe(true); - expect(isRecordablePositionBackfillVerdict({ ...base, failed: ['x'] })).toBe(false); - expect(isRecordablePositionBackfillVerdict({ ...base, conflicting: ['x'] })).toBe(false); - expect(isRecordablePositionBackfillVerdict({ ...base, stopped: 'scan-unreadable' })).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts index 16cc98e060b..e5448e8b2a4 100644 --- a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts @@ -41,10 +41,10 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysJwks, SysMember, SysUser, SysVerification } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; const CREDENTIAL_OBJECTS = ['sys_verification', 'sys_jwks'] as const; diff --git a/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts index 891f3d143d8..b4eab6b8eff 100644 --- a/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts +++ b/packages/plugins/plugin-security/src/rbac-objects-bulk-exposure.test.ts @@ -27,18 +27,10 @@ import { describe, expect, it } from 'vitest'; import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; -import { SysCapability } from './objects/sys-capability.object'; -import { SysPermissionSet } from './objects/sys-permission-set.object'; -import { SysPosition } from './objects/sys-position.object'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object'; import { SysUserPosition } from './objects/sys-user-position.object'; const RBAC_OBJECTS = [ - ['sys_capability', SysCapability], - ['sys_permission_set', SysPermissionSet], - ['sys_position', SysPosition], - ['sys_position_permission_set', SysPositionPermissionSet], ['sys_user_permission_set', SysUserPermissionSet], ['sys_user_position', SysUserPosition], ] as const; diff --git a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts index 973e700e495..ee307642726 100644 --- a/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts +++ b/packages/plugins/plugin-security/src/read-fault-distinguishability.test.ts @@ -49,9 +49,9 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..1fa34be7225 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -39,10 +39,10 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts index e6443d1a865..cfe4d91217b 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -32,10 +32,10 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts index 8848913fd7a..b474bc2b944 100644 --- a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts @@ -28,10 +28,10 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysMember, SysUser } from '@objectstack/platform-objects/identity'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts index 2d22032bae6..e37af2446ad 100644 --- a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts @@ -53,10 +53,10 @@ import { SysUser, } from '@objectstack/platform-objects/identity'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; /** The SCIM tables that name the platform user each row is about. */ diff --git a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts index a108cf61c27..442ceb2da42 100644 --- a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts +++ b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts @@ -34,10 +34,10 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysPosition } from './objects/sys-position.object.js'; +import { SysPosition } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; diff --git a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts index 36fd6aab3ed..e8e0d7aa2df 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts @@ -52,8 +52,6 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; -import { SysPermissionSet } from './objects/index.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; /** The organization the caller holds (its active organization). */ const OWN_ORG = 'org_a'; @@ -452,74 +450,7 @@ for (const [driverName, makeDriver] of DRIVERS) { expect((await b.table('qa_account'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: OTHER_ORG }); }); }); - - describe(`[#20013] the ADR-0094 permission-set data door — ${driverName}`, () => { - // The data door executes an insert/update of `sys_permission_set` ITSELF, - // through the metadata protocol, and never calls `next()`: no engine - // write, so no hook chain and no seam. Measured on the base: a platform - // administrator's insert under the `isolated` posture is admitted while - // Layer 0 walls the object (`organization_id = `). - it('⭐ a platform administrator’s permission-set insert under a walled posture is still admitted', async () => { - const engine = new ObjectQL(); - engine.registerDriver(makeDriver() as never, true); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.tenant-wall-post-hook-image.data-door', - name: 'Data door under a walled posture', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet], - } as never); - await engine.syncSchemas(); - engines.push(engine); - const saved = new Map(); - const protocol = { - registerMutationProjector() { /* not exercised here */ }, - async saveMetaItem(req: { name: string; item: unknown }) { saved.set(req.name, req.item); return { success: true }; }, - async deleteMetaItem() { return { success: true }; }, - async getMetaItemLayered(req: { name: string }) { - const body = saved.get(req.name) ?? null; - return { type: 'permission', name: req.name, code: null, overlay: body, overlayScope: null, effective: body }; - }, - }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - protocol, - metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => [...defaultPermissionSets], - }, - 'org-scoping': { name: 'com.objectstack.org-scoping' }, - tenancy: { posture: 'isolated' }, - }; - const ctx = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx as never); - await plugin.start(ctx as never); - vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); - let engineWriteRan = false; - engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { - if (opCtx.object === 'sys_permission_set' && opCtx.operation === 'insert' && !opCtx.context?.isSystem) { - engineWriteRan = true; - } - await next(); - }); - - const outcome = await attempt(() => - engine.insert('sys_permission_set', { name: 'door_set', label: 'Door set' } as never, { context: ADMIN_CTX } as never)); - - expectAdmitted(outcome); - expect(engineWriteRan, 'the data door executed the write itself').toBe(false); - expect(saved.has('door_set'), 'the definition reached the metadata store').toBe(true); - }); - }); + // [C3 stage 8] The ADR-0094 permission-set data door this file also pinned + // is unwired with the retired `sys_permission_set` object (ADR-0131 D3): a + // permission set is written through the metadata door alone. } diff --git a/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts b/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts index cb5550b902a..91676532731 100644 --- a/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts +++ b/packages/plugins/plugin-security/src/translations/bundle-ownership.test.ts @@ -14,23 +14,21 @@ // NB: this package also defines sys_audience_binding_suggestion, which the // extract config deliberately does NOT translate — an engine-owned suggestion // queue with no admin UI surface, absent from both the config and the bundles, -// correctly out of scope. `sys_capability` and `sys_user_position` ARE translated -// (curated strings live in the bundles), so they must stay in the extract config; -// this guard is what caught sys_capability being dropped from that list. +// correctly out of scope. Every other object IS translated (curated strings live +// in the bundles), so it must stay in the extract config; this guard is what +// caught sys_capability being dropped from that list. import { describe, it, expect } from 'vitest'; import { enObjects } from './en.objects.generated.js'; +import { securityObjects } from '../manifest.js'; -// Objects the extract config (scripts/i18n-extract.config.ts) imports — -// keep the two lists in sync when adding/moving objects. -const OWNED_OBJECTS = new Set([ - 'sys_position', - 'sys_capability', - 'sys_permission_set', - 'sys_user_permission_set', - 'sys_position_permission_set', - 'sys_user_position', -]); +// Objects the extract config (scripts/i18n-extract.config.ts) imports: every +// object this plugin registers, less the untranslated suggestion queue (see +// above). Read from the manifest, so an object the plugin retires (the four +// catalog row objects, ADR-0131 D3) leaves this list with it. +const OWNED_OBJECTS = new Set( + securityObjects.map((o) => o.name).filter((name) => name !== 'sys_audience_binding_suggestion'), +); describe('objects translation bundle ownership (ADR-0029 D8)', () => { it('the en bundle contains no objects owned by other packages', () => { diff --git a/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts b/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts index 8b84dfc871c..661b3c29afc 100644 --- a/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts +++ b/packages/plugins/plugin-security/src/translations/position-rename-consistency.test.ts @@ -33,8 +33,13 @@ import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; -/** The objects ADR-0090 P1 renamed — the exact surface the rename had to land on. */ -const RENAMED_OBJECTS = ['sys_position', 'sys_position_permission_set', 'sys_user_position']; +/** + * The objects ADR-0090 P1 renamed that still exist — the exact surface the + * rename had to land on. `sys_position` and `sys_position_permission_set` are + * retired with the catalog row objects (ADR-0131 D3), and their strings with + * them; `sys_user_position` remains. + */ +const RENAMED_OBJECTS = ['sys_user_position']; const LOCALES = [ // `\b` keeps this off `control` / `controlar`, which legitimately contain "rol". diff --git a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts index fbb46afc401..58f981cc875 100644 --- a/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-text-predicate.dogfood.test.ts @@ -267,8 +267,7 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(ql.registry.getItem('permission', name), `fixture permission set ${name} declared`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } if (who === 'control') { @@ -278,7 +277,6 @@ describe('[#21154] a query over activity text or the approval snapshot, by a rea // position expansion does not read, and an all-literal slate would then // open on the organization's administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: userId }, context: { isSystem: true } }); - await ql.insert('sys_position', { id: 'pos_atp', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_atp', user_id: userId, position: POSITION, organization_id: membership?.organization_id, }, SYS); diff --git a/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts index dc431b46b2f..a6e5d28f6f2 100644 --- a/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-credential-field.dogfood.test.ts @@ -149,7 +149,6 @@ describe('approval snapshot of a record with credential-class and internal field // all-literal slate would then open on the organization's // administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: approverId }, context: { isSystem: true } }); - await ql.insert('sys_position', { id: 'pos_credsnap', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_credsnap', user_id: approverId, position: POSITION, organization_id: membership?.organization_id, }, SYS); diff --git a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts index 9a8888c705e..c57fef1a7f7 100644 --- a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts @@ -194,13 +194,11 @@ describe('[#20964] approval snapshot field served masked on the data plane', () // administrators instead (#22824). const membership = await ql.findOne('sys_member', { where: { user_id: memberId }, context: { isSystem: true } }); const organization_id = membership?.organization_id; - await ql.insert('sys_position', { id: 'pos_snapmask', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_m', user_id: memberId, position: POSITION, organization_id }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_u', user_id: unmaskerId, position: POSITION, organization_id }, SYS); - const unmask = await ql.findOne('sys_permission_set', { where: { name: unmaskSet.name }, context: { isSystem: true } }); - expect(unmask?.id, 'fixture permission set seeded').toBeTruthy(); + expect(ql.registry.getItem('permission', unmaskSet.name), 'fixture permission set declared').toBeTruthy(); for (const userId of [unmaskerId, submitterId]) { - await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmask.name }, SYS); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmaskSet.name }, SYS); } const createRes = await stack.apiAs(submitterToken, 'POST', `/data/${OBJECT}`, { name: 'Item one', [KEY]: STORED }); diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index e708393c9fc..7964f32b1c5 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -250,8 +250,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED for a set that does not exist', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'no_such_set_8074' }, + object: 'sys_user', + where: { email: 'no_such_user_8074@verify.test' }, control: 'an app-declared permission set', disarmedBy: 'a set that failed to seed grants the principal nothing', }).read(); diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts deleted file mode 100644 index 0068380fded..00000000000 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#22360] A position the showcase package declares has its DEFINITION locked -// at the data door, the way the metadata door already locks it, while its ROW -// STATE stays switchable — over the real showcase composition under `single`, -// across a cold boot on one database file. -// -// ## What was broken -// -// The declared-position seeder wrote its rows with no provenance stamp, so they -// carried the object default (`managed_by: 'admin'`) and the system-row write -// gate did not protect them: a data-door label edit of `manager` answered 200, -// and the next boot wrote the declared label back over it, with no message. -// -// ## The rule now -// -// [ADR-0131 D2, stage 6a] No boot writes a `sys_position` row: the declared -// and built-in position seeders are retired, and a declared position lives in -// the registry. The rows an earlier boot wrote stay on an existing database -// (C7 removes them), stamped `managed_by: 'package'` by the seeder this file -// once pinned, and the system-row write gate still guards them: a patch -// touching only row state (`active`, `is_default` — Setup's Activate, -// Deactivate and Set as Default) passes; a definition column (`label`, -// `delegatable`, …) and a delete are refused with the gate's code. This file -// stages those rows itself ({@link stageExistingRows}), the way the retired -// seeder left them. -// -// ## What each case pins -// -// - precondition: the showcase package holds the name, the metadata door -// refuses an edit of it, and the boot wrote no position row; -// - a staged declared row: a label edit, a `delegatable` edit and a delete -// are refused with the gate's code and leave the row as it was; -// - Deactivate, Activate and Set as Default answer 200; a multi-row -// (`updateMany`) row-state patch lands, a multi-row label patch is refused; -// - after a cold boot: the boot writes nothing — the refused columns stand, -// the row-state writes persisted, a row stamped `admin` stays `admin`, a -// drifted label stays drifted, and a position saved through the metadata -// door gets no row (the catalog serves it); -// - controls: an administrator-authored position stays editable; a built-in -// row refuses even a bare `{ active }` patch. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const SHOWCASE_PACKAGE = 'com.example.showcase'; - -/** Positions the showcase package declares, with the labels it declares them with. */ -const declaredLabel = (name: string): string => { - const declared = ((showcaseStack as any).positions ?? []).find((p: any) => p?.name === name); - if (!declared?.label) throw new Error(`the showcase no longer declares a labelled position '${name}'`); - return declared.label; -}; -/** Definition edits and the delete are tried here. */ -const EDITED = 'manager'; -const DELETED = 'auditor'; -/** Row-state writes, one position each, so every write is read back on its own row. */ -const DEACTIVATED = 'contributor'; -const ACTIVATED = 'legal'; -const DEFAULTED = 'ops'; -const BULK = ['finance', 'client_liaison']; -/** The upgraded-deployment and drift simulations. */ -const UPGRADED = 'exec'; -const DRIFTED = 'field_ops_delegate'; -const ADMIN_AUTHORED = 'p22360_admin_authored'; -const ENV_AUTHORED = 'p22360_env_authored'; -const REFUSED = { status: 403, code: 'PERMISSION_DENIED' }; -/** Every declared row the cases read. */ -const STAGED = [EDITED, DELETED, DEACTIVATED, ACTIVATED, DEFAULTED, ...BULK, UPGRADED, DRIFTED]; - -describe('[#22360] a package-declared position: definition locked at the data door, row state switchable, across a cold boot (showcase)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? null, json }; - }; - const rows = async (name: string) => - ((await ql.find('sys_position', { where: { name }, limit: 2 }, SYS)) as any[]); - const one = async (name: string) => { - const found = await rows(name); - expect(found.length, `exactly one '${name}' row`).toBe(1); - return found[0]; - }; - const patch = async (name: string, body: Record) => - call('PATCH', `/data/sys_position/${(await one(name)).id}`, body); - const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); - const start = async () => { - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }; - /** Every `sys_position` row, by name, past every scope. */ - const census = async () => - ((await ql.find('sys_position', { where: {}, limit: 1000 }, SYS)) as any[]).map((r) => r.name).sort(); - let bootCensus: string[] = []; - /** - * The rows an existing database holds: what the retired seeders wrote — a - * declared position stamped `package` with its declared label, the - * `everyone` anchor stamped `platform`. - */ - const stageExistingRows = async () => { - for (const name of STAGED) { - await ql.insert('sys_position', { - id: `pos_${name}`, name, label: declaredLabel(name), managed_by: 'package', active: true, is_default: false, - }, SYS); - } - await ql.insert('sys_position', { - id: 'pos_everyone', name: 'everyone', label: 'Everyone', managed_by: 'platform', active: true, is_default: false, - }, SYS); - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-22360-')); - dbFile = join(dir, 'showcase.db'); - await start(); - bootCensus = await census(); - await stageExistingRows(); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses an edit of it', async () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - expect((ql.registry.getArtifactItem('position', EDITED) as any)?._packageId).toBe(SHOWCASE_PACKAGE); - const put = await call('PUT', `/meta/position/${EDITED}`, { name: EDITED, label: 'Manager (metadata door)' }); - expect(outcome(put), JSON.stringify(put.json)).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - // [ADR-0131 D2] The fresh boot wrote no position row at all. - expect(bootCensus).toEqual([]); - }); - - it('a label edit and a delegatable edit are refused with the gate\'s code, and the row is unchanged', async () => { - const before = await one(EDITED); - const label = await patch(EDITED, { label: 'Manager (edited in Setup)' }); - expect(outcome(label), JSON.stringify(label.json)).toEqual(REFUSED); - const delegatable = await patch(EDITED, { delegatable: !before.delegatable }); - expect(outcome(delegatable), JSON.stringify(delegatable.json)).toEqual(REFUSED); - const after = await one(EDITED); - expect({ label: after.label, delegatable: after.delegatable }) - .toEqual({ label: declaredLabel(EDITED), delegatable: before.delegatable }); - }); - - it('a delete is refused with the gate\'s code, and the row stays', async () => { - const row = await one(DELETED); - const deleted = await call('DELETE', `/data/sys_position/${row.id}`); - expect(outcome(deleted), JSON.stringify(deleted.json)).toEqual(REFUSED); - expect((await rows(DELETED)).map((r) => r.id)).toEqual([row.id]); - }); - - it('Deactivate, Activate and Set as Default answer 200 and land on the row', async () => { - const deactivated = await patch(DEACTIVATED, { active: false }); - expect(deactivated.status, JSON.stringify(deactivated.json)).toBe(200); - - // Activate needs a deactivated row first; only a system write can make one here - // without going through the door under test. - await ql.update('sys_position', { id: (await one(ACTIVATED)).id, active: false }, SYS); - const activated = await patch(ACTIVATED, { active: true }); - expect(activated.status, JSON.stringify(activated.json)).toBe(200); - - const defaulted = await patch(DEFAULTED, { is_default: true }); - expect(defaulted.status, JSON.stringify(defaulted.json)).toBe(200); - - expect({ - deactivated: (await one(DEACTIVATED)).active, - activated: (await one(ACTIVATED)).active, - defaulted: (await one(DEFAULTED)).is_default, - }).toEqual({ deactivated: false, activated: true, defaulted: true }); - }); - - it('a multi-row row-state patch lands, and a multi-row label patch over a package row is refused', async () => { - const ids = await Promise.all(BULK.map(async (name) => (await one(name)).id)); - const switched = await call('POST', '/data/sys_position/updateMany', { - records: ids.map((id) => ({ id, data: { active: false } })), - }); - expect(switched.status, JSON.stringify(switched.json)).toBe(200); - expect(await Promise.all(BULK.map(async (name) => (await one(name)).active))).toEqual([false, false]); - - const relabelled = await call('POST', '/data/sys_position/updateMany', { - records: [{ id: ids[0], data: { label: 'Finance (edited in Setup)' } }], - }); - const refusals = JSON.stringify(relabelled.json); - expect(refusals, `status ${relabelled.status}`).toContain('PERMISSION_DENIED'); - expect((await one(BULK[0])).label).toBe(declaredLabel(BULK[0])); - }); - - it('control: an administrator-authored position stays editable', async () => { - const created = await call('POST', '/data/sys_position', { name: ADMIN_AUTHORED, label: 'Admin authored' }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - expect((await one(ADMIN_AUTHORED)).managed_by).toBe('admin'); - const edited = await patch(ADMIN_AUTHORED, { label: 'Admin authored (edited)' }); - expect(edited.status, JSON.stringify(edited.json)).toBe(200); - expect((await one(ADMIN_AUTHORED)).label).toBe('Admin authored (edited)'); - }); - - it('control: a built-in refuses even a bare { active } patch', async () => { - const everyone = await one('everyone'); - expect(everyone.managed_by).toBe('platform'); - const switched = await patch('everyone', { active: false }); - expect(outcome(switched), JSON.stringify(switched.json)).toEqual(REFUSED); - const relabelled = await patch('everyone', { label: 'Everyone (edited)' }); - expect(outcome(relabelled), JSON.stringify(relabelled.json)).toEqual(REFUSED); - expect((await one('everyone')).active).toBe(everyone.active); - }); - - it('after a cold boot: the boot writes nothing — the refused columns stand, the row-state writes persisted, and no row is re-stamped, refreshed or added', async () => { - // The upgraded shape: a row stamped with the object default (`admin`) - // carrying a column an administrator set. - const upgraded = await one(UPGRADED); - await ql.update('sys_position', { id: upgraded.id, managed_by: 'admin', delegatable: true }, SYS); - // Drifted display text, which only a system write can reach now. - await ql.update('sys_position', { id: (await one(DRIFTED)).id, label: 'Field ops delegate (stale)' }, SYS); - // An environment-authored position: a metadata-door save. The catalog - // serves it; no boot writes its row. - const saved = await call('PUT', `/meta/position/${ENV_AUTHORED}`, { name: ENV_AUTHORED, label: 'Environment authored' }); - expect(saved.status, JSON.stringify(saved.json)).toBe(200); - const before = await census(); - - await stack!.stop(); - await start(); - - expect(await census()).toEqual(before); - const edited = await one(EDITED); - expect({ label: edited.label, managed_by: edited.managed_by }) - .toEqual({ label: declaredLabel(EDITED), managed_by: 'package' }); - expect((await rows(DELETED)).length).toBe(1); - expect({ - deactivated: (await one(DEACTIVATED)).active, - activated: (await one(ACTIVATED)).active, - defaulted: (await one(DEFAULTED)).is_default, - bulk: await Promise.all(BULK.map(async (name) => (await one(name)).active)), - }).toEqual({ deactivated: false, activated: true, defaulted: true, bulk: [false, false] }); - - const kept = await one(UPGRADED); - expect({ managed_by: kept.managed_by, delegatable: kept.delegatable }).toEqual({ managed_by: 'admin', delegatable: true }); - expect((await one(DRIFTED)).label).toBe('Field ops delegate (stale)'); - expect(await rows(ENV_AUTHORED)).toEqual([]); - const served = await call('GET', `/meta/position/${ENV_AUTHORED}`); - expect(served.status, JSON.stringify(served.json)).toBe(200); - // This case boots the showcase again (`start()`), so it carries the - // budget `beforeAll` gives that same `start()`, not vitest's 5000 ms - // default. Since #22301 the showcase boots with the providers its - // `requires` names and its own `plugins`, as `os serve` boots it: the - // reboot read 2809 ms on a quiet shard and timed out at 5225 ms on a - // loaded one. - }, 300_000); - - it('control after the cold boot: an administrator-authored position keeps its unmanaged, edited row', async () => { - const admin = await one(ADMIN_AUTHORED); - expect([admin.label, admin.managed_by]).toEqual(['Admin authored (edited)', 'admin']); - }); -}); diff --git a/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts index 3d936fe5f0b..e1d37b10654 100644 --- a/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts @@ -134,7 +134,6 @@ describe('[#22738] a lookup title is served only for a target whose declared exp const memberId = await userId('lte-member@verify.test'); const org = (await ql.findOne('sys_member', { where: { user_id: memberId }, ...SYS }))?.organization_id; expect(org, 'the member belongs to an organization').toBeTruthy(); - await ql.insert('sys_position', { id: 'pos_lte', name: POSITION, label: 'Reviewer', active: true, organization_id: org }, SYS); for (const id of [await userId('admin@objectos.ai'), memberId]) { await ql.insert('sys_user_position', { user_id: id, position: POSITION, organization_id: org }, SYS); } diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index daeb76e49a9..3013895be94 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -110,17 +110,15 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); // Author the property: a set whose ONLY job is tabPermissions.hidden. - // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the - // write-through stores the definition the security catalog resolves. A - // system-context row insert has no definition and grants nothing. - const hideSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + // [ADR-0131 D3/D4] Authored through the metadata door, the security + // catalog's write path: the definition it stores is what the resolver reads. + const hideSet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/tab_probe_hide', { name: 'tab_probe_hide', label: 'Tab probe — hide showcase', - tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), + objects: {}, + tabPermissions: { showcase_app: 'hidden' }, }); - expect(hideSet.status, 'set authored through the data door').toBeLessThan(300); - const hideSetId = (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; - expect(hideSetId, 'hide set stored').toBeTruthy(); + expect(hideSet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set: 'tab_probe_hide' }, { context: SYS }); const hidden = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -129,12 +127,13 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — // most-visible wins across the principal's sets, matching hono's tabRank. - const showSet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + const showSet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/tab_probe_show', { name: 'tab_probe_show', label: 'Tab probe — show showcase', - tab_permissions: JSON.stringify({ showcase_app: 'visible' }), + objects: {}, + tabPermissions: { showcase_app: 'visible' }, }); - expect(showSet.status, 'set authored through the data door').toBeLessThan(300); + expect(showSet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set: 'tab_probe_show' }, { context: SYS }); const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); @@ -156,7 +155,6 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { const everyone = await reader.resolve('position', 'everyone'); expect((everyone?.definition as any)?.permissionSets, 'everyone names member_default (bootstrap no longer refuses)') .toContain('member_default'); - expect(await ql.find('sys_position', { where: { name: 'everyone' }, context: SYS }), 'no everyone row is written').toEqual([]); }); it('deleting records is no longer a baseline right (anchor-forbidden bit removed)', async () => { @@ -174,16 +172,14 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // some wildcard happened to grant create. const memberUser = await ql.findOne('sys_user', { where: { email: 'baseline-member@verify.test' }, context: SYS }); expect(memberUser?.id, 'baseline member resolved').toBeTruthy(); - const inquirySet = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + const inquirySet = await stack.apiAs(adminTok, 'PUT', '/meta/permission/baseline_inquiry_probe', { name: 'baseline_inquiry_probe', label: 'Baseline probe — inquiry create, deliberately no delete', - object_permissions: JSON.stringify({ + objects: { showcase_inquiry: { allowRead: true, allowCreate: true }, - }), + }, }); - expect(inquirySet.status, 'set authored through the data door').toBeLessThan(300); - const inquirySetId = (await ql.findOne('sys_permission_set', { where: { name: 'baseline_inquiry_probe' }, context: SYS }))?.id; - expect(inquirySetId, 'probe set stored').toBeTruthy(); + expect(inquirySet.status, 'set authored through the metadata door').toBeLessThan(300); await ql.insert( 'sys_user_permission_set', { user_id: memberUser.id, permission_set: 'baseline_inquiry_probe' }, diff --git a/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts b/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts new file mode 100644 index 00000000000..702198ce71d --- /dev/null +++ b/packages/qa/dogfood/test/permission-metadata-door-reaches-catalog.dogfood.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A permission set saved through the METADATA door is what the security catalog +// serves, on the real composition: a create, an edit and a delete each reach the +// by-name read the authorization resolver uses (ADR-0131 D3), and a grant naming +// the set is accepted exactly while the catalog holds it (ADR-0131 D4). +// +// ## Why this exists +// +// Until C3 stage 8 the `permission` mutation projector rode along on every save: +// it mirrored the body into a `sys_permission_set` row and, gated on that row +// write, echoed the body into the metadata service's in-memory registry. Stage +// 8 retires the row object and unwires the projector, so this pins the premise +// that ruling rested on — the metadata door's own registry write-through, and +// nothing the projector did, is what puts a saved set in the catalog. It holds +// with the projector wired and without it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { createSecurityCatalogReader } from '@objectstack/core'; +import { bootShowcase } from './showcase-boot.js'; + +const SET = 'door_reach_auditor'; +const SYS = { context: { isSystem: true } } as const; + +describe('a metadata-door permission save reaches the security catalog (ADR-0131 D3/D4)', () => { + let stack: VerifyStack; + let admin: string; + let reader: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let userId: string; + + beforeAll(async () => { + stack = await bootShowcase(); + admin = await stack.signIn(); + await stack.signUp('door-reach@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack.kernel.getService('metadata') }); + userId = (await ql.findOne('sys_user', { where: { email: 'door-reach@verify.test' }, ...SYS }))?.id; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + const grant = (id: string) => + stack.apiAs(admin, 'POST', '/data/sys_user_permission_set', { id, user_id: userId, permission_set: SET }); + + it('PRECONDITION: the name is held by nobody, so a grant naming it is refused', async () => { + expect(userId).toBeTruthy(); + expect(await reader.resolve('permission', SET)).toBeUndefined(); + const refused = await grant('ups_door_before'); + expect(refused.status, await refused.clone().text()).toBe(400); + }); + + it('a create: the catalog serves the saved body, and a grant naming it is accepted', async () => { + const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${SET}`, { + name: SET, label: 'Door v1', objects: { sys_user: { allowRead: true } }, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + + const entry = await reader.resolve('permission', SET); + expect(entry?.definition).toMatchObject({ name: SET, label: 'Door v1', objects: { sys_user: { allowRead: true } } }); + + const accepted = await grant('ups_door_after'); + expect(accepted.status, await accepted.clone().text()).toBeLessThan(300); + }); + + it('an edit: the catalog serves the new body, not the one first saved', async () => { + const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${SET}`, { + name: SET, label: 'Door v2', objects: {}, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + + const entry = await reader.resolve('permission', SET); + expect(entry?.definition).toMatchObject({ name: SET, label: 'Door v2' }); + expect((entry?.definition as { objects?: Record }).objects ?? {}).not.toHaveProperty('sys_user'); + }); + + it('a delete: the catalog no longer serves the name', async () => { + await ql.delete('sys_user_permission_set', { where: { id: 'ups_door_after' }, ...SYS }); + const deleted = await stack.apiAs(admin, 'DELETE', `/meta/permission/${SET}`); + expect(deleted.status, await deleted.clone().text()).toBeLessThan(300); + + expect(await reader.resolve('permission', SET)).toBeUndefined(); + const refused = await grant('ups_door_gone'); + expect(refused.status, await refused.clone().text()).toBe(400); + }); +}); diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index f2bd9f53363..ab7e476bd7f 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -39,6 +39,7 @@ interface Engine { find(object: string, options?: unknown): Promise>>; insert(object: string, data: Record, options?: unknown): Promise; delete(object: string, options?: unknown): Promise; + getObject?(object: string): unknown; } describe('the permission-set clone door, on a booted showcase', () => { @@ -68,11 +69,15 @@ describe('the permission-set clone door, on a booted showcase', () => { expect(source?.packageId, 'the source is not packaged on this boot').toBeTruthy(); expect(source?.definition.adminScope, 'the source carries no adminScope').toBeTruthy(); - // The source's catalog ROWS are removed first: the door reads the - // catalog, so the clone must land with no row to read. - const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); - for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); - expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); + // The source's catalog ROWS are removed first, where the row object is + // still registered: the door reads the catalog, so the clone must land + // with no row to read. (C3 stage 8 retires the object, and there are + // then no rows at all.) + if (ql.getObject?.('sys_permission_set')) { + const rows = await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX }); + for (const row of rows) await ql.delete('sys_permission_set', { where: { id: row.id }, context: SYSTEM_CTX }); + expect(await ql.find('sys_permission_set', { where: { name: DELEGATE_SET }, context: SYSTEM_CTX })).toEqual([]); + } const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' }); const text = await res.clone().text(); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts deleted file mode 100644 index 72ccea5f54a..00000000000 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21789] The packaged-permission-set lock fires only for a set a code -// (artifact) package ships, judged from the row's provenance — and the read -// the console renders from reports the same judgment the write doors enforce. -// Over the real showcase composition, across a cold boot on one database file. -// -// ## What was broken -// -// The lock (`packaged-permission-set-lock.ts`, plugin-security) asked the -// engine registry "does any item of this name carry a package id?". The -// registry holds stored rows as well as artifacts, and a list read -// (`GET /meta/permission`) hydrates every env-wide `sys_metadata` row into it -// with the row's `package_id` column stamped onto the body as `_packageId`. -// So a set saved into a writable runtime package read as -// code-shipped after the first list read, and every edit of it answered -// `403 NOT_OVERRIDABLE` at both doors. The provenance that tells the two apart -// was on the same body all along: the hydrator writes `_provenance: 'org'` on -// every stored row (ADR-0010), and `isCodeArtifactBody` already reads it. -// -// The READ had its own half. The security plugin syncs an overlay-backed body -// into the metadata manager as a marked "projection echo", and the protocol's -// layered read serves that echo as the item's `code` layer. The echo carried -// no provenance, so an org's own set, a clone, and a runtime-package set all -// reported a code layer with no provenance, which is how objectui's -// permission-matrix editor reads "a code package ships this": it rendered them -// locked while the doors accepted the save. The echo now carries the lock's -// verdict (`_provenance: 'org'` exactly when the lock answers `org`). -// -// Measured on `origin/main` (088428fb) through this file's own steps before the -// fix: the runtime-package set's registry row was -// `{ _packageId: , _provenance: 'org' }` after the list read, -// then `PUT /meta/permission/?package=` and -// `PATCH /data/sys_permission_set/` both answered 403 `NOT_OVERRIDABLE`; -// all three shapes' layered reads served a `code` layer with no provenance and -// no envelope `provenance`, beside `editable: true`. -// -// ## The pins hold both directions -// -// The lock's declared population is code-shipped sets. This file pins the three -// shapes the card names as editable (the metadata door, the data door, and the -// layered read's report), and pins that a set the showcase package ships is -// still refused at both doors with the same code and status and is still -// reported code-shipped by the read. -// -// ## Why a booted stack, booted twice -// -// The lock's input has one producer, the list read: boot 1 issues it and -// asserts the runtime package was stamped onto the set's registry row BEFORE it -// asserts any edit lands — without that precondition an accepted edit would -// prove nothing. (The boot's own hydration does not stamp the package id; that -// was measured, so no cold-boot leg pretends to exercise it.) The read's input -// has a second producer: the projection echo is minted again by the boot's -// reconciliation rather than by a save, so boot 2, on the same file, reads the -// three shapes again before anything is written. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { securityObjects } from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const API_BASE = '/api/v1'; - -/** A writable runtime package, created through `POST /packages`. */ -const PKG = 'com.dogfood.lock21789'; -/** Shape 1 — a set saved into that runtime package. */ -const PKG_SET = 'lock21789_pkgset'; -/** Shape 2 — a set the org created through the data door. */ -const ORG_SET = 'lock21789_orgset'; -/** Shape 3 — a "Clone to customize" clone of a packaged set. */ -const CLONE = 'lock21789_clone'; -/** The control: a set the showcase package (`com.example.showcase`) ships. */ -const SHIPPED = 'showcase_contributor'; -const SHIPPED_PACKAGE = 'com.example.showcase'; - -interface Shape { label: string; name: string; packageQuery: string } -const SHAPES: Shape[] = [ - { label: 'runtime-package set', name: PKG_SET, packageQuery: `?package=${PKG}` }, - { label: 'org-owned set', name: ORG_SET, packageQuery: '' }, - { label: 'clone', name: CLONE, packageQuery: '' }, -]; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -/** Both doors answer this refusal as `{ error: , code }`: the code is top-level. */ -const refusalCode = (json: any): unknown => json?.code; - -describe('[#21789] the permission-set lock reads the row\'s provenance — the three org-owned shapes edit, a code-shipped set stays refused (showcase)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const registryRows = (name: string): any[] => - (ql.registry.listItems('permission') ?? []).filter((i: any) => i?.name === name); - - const layered = async (name: string): Promise => { - const res = await stack!.apiAs(token, 'GET', `/meta/permission/${name}/layers`); - expect(res.status, `layered read of ${name}`).toBe(200); - const json: any = await res.json(); - return json?.data ?? json; - }; - - /** A metadata-door save of the whole definition, through the door the console's editor uses. */ - const putDefinition = async (shape: Pick, label: string) => { - const res = await stack!.apiAs(token, 'PUT', `/meta/permission/${shape.name}${shape.packageQuery}`, { - name: shape.name, - label, - objects: { showcase_task: { allowRead: true } }, - }); - return { status: res.status, json: await res.json().catch(() => ({})) }; - }; - - /** A data-door edit of the record, the way Setup saves it. */ - const patchRecord = async (name: string, description: string) => { - const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); - expect(row?.id, `the ${name} record exists`).toBeTruthy(); - const res = await stack!.apiAs(token, 'PATCH', `/data/sys_permission_set/${row.id}`, { description }); - return { status: res.status, json: await res.json().catch(() => ({})), id: row.id as string }; - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21789-')); - dbFile = join(dir, 'showcase.db'); - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // Shape 1: a writable runtime package, and a set saved into it. - const pkg = await stack.apiAs(token, 'POST', '/packages', { - manifest: { id: PKG, name: 'Lock provenance probe', version: '0.1.0', type: 'app' }, - }); - expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); - const saved = await putDefinition({ name: PKG_SET, packageQuery: `?package=${PKG}` }, 'Runtime package set'); - expect(saved.status, JSON.stringify(saved.json)).toBe(200); - - // Shape 2: the org's own set, created through the data door. - const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { - name: ORG_SET, - label: 'Org-owned set', - object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), - }); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. - const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (clone)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const cloned = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(cloned.status, JSON.stringify(await cloned.clone().json().catch(() => ({})))).toBe(201); - - // The producer the card names: the list read a Studio page load issues. - const list = await stack.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the list read stamped the runtime package onto its set\'s registry row, as a stored row', () => { - // ⛔ Without this, an accepted edit below proves nothing: a list read - // that stopped stamping the package id would also leave the set editable. - const rows = registryRows(PKG_SET); - expect(rows.map((r) => ({ _packageId: r._packageId ?? null, _provenance: r._provenance ?? null }))) - .toEqual([{ _packageId: PKG, _provenance: 'org' }]); - }); - - for (const shape of SHAPES) { - it(`${shape.label}: the metadata door accepts an edit, and the edit lands`, async () => { - const label = `${shape.label} (edited at the metadata door)`; - const put = await putDefinition(shape, label); - expect(put.status, JSON.stringify(put.json)).toBe(200); - expect((await layered(shape.name))?.effective?.label).toBe(label); - }); - - it(`${shape.label}: the data door accepts an edit, and the edit lands`, async () => { - const description = `${shape.label} (edited at the data door)`; - const patch = await patchRecord(shape.name, description); - expect(patch.status, JSON.stringify(patch.json)).toBe(200); - const [after] = await ql.find('sys_permission_set', { where: { id: patch.id }, limit: 1 }, SYS); - expect(after?.description).toBe(description); - }); - - it(`${shape.label}: the layered read reports it editable and tenant-authored, not code-shipped`, async () => { - // The console's artifact test reads the `code` layer and the - // envelope's `provenance`; `'org'` is the value it carves out. - const env = await layered(shape.name); - expect({ editable: env?.editable, provenance: env?.provenance }).toEqual({ editable: true, provenance: 'org' }); - }); - } - - it('control: a set the showcase package ships is still refused at both doors with 403 NOT_OVERRIDABLE', async () => { - const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); - expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - const patch = await patchRecord(SHIPPED, 'customized'); - expect({ status: patch.status, code: refusalCode(patch.json) }, JSON.stringify(patch.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - }); - - it('control: the layered read still reports the shipped set as code-shipped and not editable', async () => { - const env = await layered(SHIPPED); - expect({ - editable: env?.editable, - provenance: env?.provenance, - packageId: env?.packageId, - codePackageId: env?.code?._packageId, - }).toEqual({ editable: false, provenance: 'package', packageId: SHIPPED_PACKAGE, codePackageId: SHIPPED_PACKAGE }); - }); - - describe('after a cold boot on the same database file', () => { - // The echo the read serves is minted again here by the boot's - // reconciliation, not by a save: nothing is written before the reads. - beforeAll(async () => { - await stack?.stop(); - stack = undefined; - stack = await bootShowcase({ databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - it('the echo the boot mints carries the lock\'s verdict: all three shapes read editable and tenant-authored', async () => { - for (const shape of SHAPES) { - const env = await layered(shape.name); - expect({ name: shape.name, editable: env?.editable, provenance: env?.provenance }) - .toEqual({ name: shape.name, editable: true, provenance: 'org' }); - } - }); - - it('control: the shipped set is still refused at the metadata door with 403 NOT_OVERRIDABLE', async () => { - const put = await putDefinition({ name: SHIPPED, packageQuery: '' }, 'Contributor (customized)'); - expect({ status: put.status, code: refusalCode(put.json) }, JSON.stringify(put.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - }); - }); -}); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts deleted file mode 100644 index f0dba0ef8cc..00000000000 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21861] A data-door edit of a permission set updates the set's OWN -// `sys_metadata` row. For a set saved into a writable runtime package, that row -// is bound to the package, and the edit must land on it rather than beside it. -// Over the real showcase composition. -// -// ## What was broken -// -// The data door (`PATCH /api/v1/data/sys_permission_set/:id`) redirects a -// definition edit into the metadata store (`createPermissionSetWriteThrough`, -// plugin-security). Its update leg merged the patch into the stored body and -// saved it with no package. A `sys_metadata` row is keyed -// `(org, type, name, package_id)`, and a save that names no package targets the -// package-less row, so for a set whose only row was bound to a runtime package -// the save minted a SECOND, package-less active row carrying the edit, and left -// the package-bound row as it was. Two active rows for one name; the package's -// copy of the set silently stopped receiving the org's edits. -// -// Measured on `origin/main` (88a39c09) through this file's own steps before the -// fix: the runtime-package set's first data-door edit answered `200`, and the -// active rows for its name went from one, bound to the package, to two: the -// untouched package-bound row and a package-less row carrying the edit. -// -// ## The pins hold all three populations -// -// - a set saved into a writable runtime package: `200`, exactly one active row -// before and after, still bound to the package and carrying the edit. Edited -// twice, before and after the list read every Studio page load issues, -// because that read is what made the edit reachable from Setup at all (it -// used to trip the packaged-set lock until the lock read the row's -// provenance); -// - a package-less set: `200`, one package-less row before and after, carrying -// the edit — the fix must not start binding sets that have no package; -// - a set a code package ships: still `403 NOT_OVERRIDABLE`, and the edit -// mints no row — the lock is untouched. -// -// Rows are counted under both type spellings, across every scope, so a fork in -// any of them is visible. Each case counts before and after its edit. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; - -/** A writable runtime package, created through `POST /packages`. */ -const PKG = 'com.dogfood.bind21861'; -/** A set saved into that package through the metadata door. */ -const PKG_SET = 'bind21861_pkgset'; -/** A set the org created through the data door: no package. */ -const ORG_SET = 'bind21861_orgset'; -/** The control: a set the showcase package (`com.example.showcase`) ships. */ -const SHIPPED = 'showcase_contributor'; - -/** Both doors answer the lock's refusal as `{ error: , code }`: the code is top-level. */ -const refusalCode = (json: any): unknown => json?.code; - -interface StoredRow { organization_id: string | null; package_id: string | null; description: unknown } - -describe('[#21861] a data-door edit of a permission set updates its own sys_metadata row (showcase)', () => { - let prevCwd: string; - let dir: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - /** - * Every ACTIVE stored row for `name`, under either type spelling and in any - * scope, as its scope, its package binding and the description its body - * carries. - */ - const activeRows = async (name: string): Promise => { - const rows: any[] = []; - for (const type of ['permission', 'permissions']) { - rows.push(...await ql.find('sys_metadata', { where: { type, name, state: 'active' }, limit: 10 }, SYS)); - } - return rows.map((r) => { - const body = typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata; - return { - organization_id: r.organization_id ?? null, - package_id: r.package_id ?? null, - description: body?.description, - }; - }); - }; - - /** A data-door edit of the record, the way Setup saves it. */ - const patchRecord = async (name: string, description: string) => { - const [row] = await ql.find('sys_permission_set', { where: { name }, limit: 1 }, SYS); - expect(row?.id, `the ${name} record exists`).toBeTruthy(); - const res = await stack!.apiAs(token, 'PATCH', `/data/sys_permission_set/${row.id}`, { description }); - return { status: res.status, json: await res.json().catch(() => ({})), id: row.id as string }; - }; - - /** Edit `name` through the data door and assert it landed on its one row, bound to `packageId`. */ - const editLandsOnItsOwnRow = async (name: string, packageId: string | null, description: string) => { - const before = await activeRows(name); - expect(before.map(({ organization_id, package_id }) => ({ organization_id, package_id })), 'before the edit') - .toEqual([{ organization_id: null, package_id: packageId }]); - - const patch = await patchRecord(name, description); - expect(patch.status, JSON.stringify(patch.json)).toBe(200); - - const after = await activeRows(name); - expect(after, 'after the edit: still one active row, same binding, carrying the edit') - .toEqual([{ organization_id: null, package_id: packageId, description }]); - const [record] = await ql.find('sys_permission_set', { where: { id: patch.id }, limit: 1 }, SYS); - expect(record?.description, 'the projected record follows the row').toBe(description); - }; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21861-')); - stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // A writable runtime package, and a set saved into it at the metadata door. - const pkg = await stack.apiAs(token, 'POST', '/packages', { - manifest: { id: PKG, name: 'Write-through binding probe', version: '0.1.0', type: 'app' }, - }); - expect(pkg.status, JSON.stringify(await pkg.clone().json().catch(() => ({})))).toBe(201); - const saved = await stack.apiAs(token, 'PUT', `/meta/permission/${PKG_SET}?package=${PKG}`, { - name: PKG_SET, - label: 'Runtime package set', - objects: { showcase_task: { allowRead: true } }, - }); - expect(saved.status, JSON.stringify(await saved.clone().json().catch(() => ({})))).toBe(200); - - // The org's own set, created through the data door. - const created = await stack.apiAs(token, 'POST', '/data/sys_permission_set', { - name: ORG_SET, - label: 'Org-owned set', - object_permissions: JSON.stringify({ showcase_task: { allowRead: true } }), - }); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('runtime-package set: the data-door edit answers 200 and lands on its own package-bound row', async () => { - await editLandsOnItsOwnRow(PKG_SET, PKG, 'Edited at the data door'); - }); - - it('runtime-package set: the same holds after the list read every Studio page load issues', async () => { - const list = await stack!.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); - await editLandsOnItsOwnRow(PKG_SET, PKG, 'Edited again, after the list read'); - }); - - it('package-less set: the data-door edit still saves package-less, on its one row', async () => { - await editLandsOnItsOwnRow(ORG_SET, null, 'Org set, edited at the data door'); - }); - - it('control: a set a code package ships is still refused with 403 NOT_OVERRIDABLE, and no row is minted', async () => { - const before = await activeRows(SHIPPED); - const patch = await patchRecord(SHIPPED, 'customized'); - expect({ status: patch.status, code: refusalCode(patch.json) }, JSON.stringify(patch.json)) - .toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - expect(await activeRows(SHIPPED), 'the refused edit stored nothing').toEqual(before); - }); -}); diff --git a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts deleted file mode 100644 index 3061db9b4ca..00000000000 --- a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [ADR-0131 D3, C2 stage S7] Under `single`, a position created or edited in -// Setup is written through to the environment ledger, and the positions Setup -// wrote before that are backfilled into it once — over the real showcase -// composition, through the real data door and the real metadata door. -// -// ## What was missing -// -// Measured on `origin/main` before this stage: `POST /api/v1/data/sys_position` -// answered 201 and wrote a row and nothing else — no `sys_metadata` row, and the -// security catalog read (`createSecurityCatalogReader`) did not resolve the name. -// A metadata-door save of a position, conversely, got its row only at the next -// boot. A Setup position had no registry home, so the planned read switch -// (stage S8) would stop it granting. -// -// ## What the pins hold -// -// - a Setup create: 201, its row, an environment-scoped `sys_metadata` row, -// and the catalog read resolves it; a rename moves the definition; a delete -// removes both; -// - a create named outside the metadata door's grammar is refused with the -// door's own answer (400 INVALID_REQUEST, 422 INVALID_METADATA) and keeps no -// row (seat re-rule Q1 = A); -// - a package registering a name a Setup position now holds in the -// environment ledger is refused 422 NAMESPACE_CONFLICT (Q3 = A); -// - the backfill: on a deployment upgraded from before the stage (row-only -// positions, no verdict in `sys_migration`), the next boot gives each -// row-only position its definition and records the verdict; a name the -// metadata door refuses stays row-only as the final reported class; a third -// boot changes nothing. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { createSecurityCatalogReader } from '@objectstack/core'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const SYS = { context: { isSystem: true } } as const; -/** The backfill's ledger row (`position-environment-backfill.ts`). */ -const LEDGER_ID = 'adr-0131-position-environment-backfill'; - -describe('[ADR-0131 D3] Setup positions reach the environment ledger under single (showcase)', () => { - let dir: string; - let db: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? json?.error?.code ?? null, json }; - }; - const rows = async (name: string) => ql.find('sys_position', { where: { name }, limit: 10 }, SYS); - const envRows = async (name: string) => - ((await ql.find('sys_metadata', { where: { type: 'position', name }, limit: 10 }, SYS)) as any[]) - .map((r) => ({ organization_id: r.organization_id ?? null, state: r.state })); - const resolves = async (name: string) => { - const reader = createSecurityCatalogReader({ registry: ql.registry, metadata: stack!.kernel.getService('metadata') }); - const entry = await reader.resolve('position', name); - return entry !== undefined && entry.name === name; - }; - const start = async () => { - stack = await bootShowcase({ databaseFile: db }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }; - - beforeAll(async () => { - dir = mkdtempSync(join(tmpdir(), 'dogfood-15196-s7-')); - db = join(dir, 'showcase.db'); - await start(); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: the boot runs the single posture', () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - }); - - it('a Setup create lands its row and an environment definition the catalog read resolves', async () => { - const created = await call('POST', '/data/sys_position', { - name: 's7_regional_lead', label: 'Regional lead', description: 'Leads a region', - }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - expect((await rows('s7_regional_lead')).length).toBe(1); - expect(await envRows('s7_regional_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await resolves('s7_regional_lead')).toBe(true); - }); - - it('a rename moves the definition, and a delete removes the row and the definition', async () => { - await call('POST', '/data/sys_position', { name: 's7_night_lead', label: 'Night lead' }); - const [row] = await rows('s7_night_lead'); - const renamed = await call('PATCH', `/data/sys_position/${row.id}`, { name: 's7_evening_lead' }); - expect(renamed.status, JSON.stringify(renamed.json)).toBe(200); - expect(await envRows('s7_night_lead')).toEqual([]); - expect(await envRows('s7_evening_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect({ old: await resolves('s7_night_lead'), neu: await resolves('s7_evening_lead') }).toEqual({ old: false, neu: true }); - - const deleted = await call('DELETE', `/data/sys_position/${row.id}`); - expect(deleted.status, JSON.stringify(deleted.json)).toBe(200); - expect(await rows('s7_evening_lead')).toEqual([]); - expect(await envRows('s7_evening_lead')).toEqual([]); - expect(await resolves('s7_evening_lead')).toBe(false); - }); - - it('Q1: a create named outside the metadata door grammar answers the door\'s refusal and keeps no row', async () => { - const spaced = await call('POST', '/data/sys_position', { name: 'S7 Lead', label: 'x' }); - expect({ status: spaced.status, code: spaced.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' }); - const dotted = await call('POST', '/data/sys_position', { name: 's7.lead', label: 'x' }); - expect({ status: dotted.status, code: dotted.code }).toEqual({ status: 422, code: 'INVALID_METADATA' }); - expect(await rows('S7 Lead')).toEqual([]); - expect(await rows('s7.lead')).toEqual([]); - }); - - it('Q3: a package registering a name a Setup position holds is refused 422 NAMESPACE_CONFLICT', async () => { - let thrown: any = null; - try { - ql.registry.registerItem('position', { name: 's7_regional_lead', label: 'Pkg' }, 'name', 'com.dogfood.s7'); - } catch (e) { thrown = e; } - expect({ code: thrown?.code, status: thrown?.status }).toEqual({ code: 'NAMESPACE_CONFLICT', status: 422 }); - }); - - it('the backfill: an upgraded deployment\'s row-only positions get their definitions on the next boot, once', async () => { - // The upgraded shape: rows the pre-stage data door wrote (system writes are - // never translated), and no backfill verdict in the ledger. - await ql.insert('sys_position', [ - { id: 'pos_s7_legacy', name: 's7_legacy_lead', label: 'Legacy lead' }, - { id: 'pos_s7_illegal', name: 'S7 Legacy', label: 'Illegal name' }, - ], SYS); - await ql.delete('sys_migration', { where: { id: LEDGER_ID }, ...SYS }); - expect({ legacy: await resolves('s7_legacy_lead'), illegal: await resolves('S7 Legacy') }) - .toEqual({ legacy: false, illegal: false }); - - await stack!.stop(); - await start(); - expect(await envRows('s7_legacy_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await resolves('s7_legacy_lead')).toBe(true); - expect(await resolves('S7 Legacy'), 'a name the door refuses stays row-only, reported').toBe(false); - const ledger = (await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]; - expect(ledger.length).toBe(1); - expect(JSON.parse(ledger[0].details)).toMatchObject({ backfilled: 1, refusedName: 1 }); - - await stack!.stop(); - await start(); - expect(await envRows('s7_legacy_lead'), 'a third boot writes nothing').toEqual([{ organization_id: null, state: 'active' }]); - expect(((await ql.find('sys_migration', { where: { id: LEDGER_ID }, limit: 2 }, SYS)) as any[]).length).toBe(1); - }, 300_000); -}); diff --git a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts b/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts deleted file mode 100644 index c39994861ec..00000000000 --- a/packages/qa/dogfood/test/position-held-name-setup-create.dogfood.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [ADR-0131 D3, ADR-0048 addendum N.2/N.3] Under `single`, a Setup create of a -// position — or a rename into a name — that a package or a built-in already -// holds answers the metadata door's own refusal, `403 NOT_OVERRIDABLE`, -// through the data door: over the real showcase composition, over HTTP. -// -// ## Why an HTTP pin -// -// The refusal is pinned in `plugin-security`'s unit harness -// (`position-write-through.test.ts`), against the real metadata door but -// below the HTTP layer. What a Setup page receives is what the REST layer -// makes of that refusal — the status, the code, and whether the row the -// write-through wrote first is really undone — so this file asks the -// composed runtime, as an administrator's session does. -// -// ## What the pins hold -// -// - precondition: the single posture; the showcase package holds `manager`, -// and the metadata door refuses a save of it with `403 NOT_OVERRIDABLE`; -// - `POST /api/v1/data/sys_position` under that name, and under the built-in -// audience anchor `everyone`, answers the door's status and code, and -// leaves the name's rows exactly as they were, with no environment -// definition written; -// - a rename into a held name answers the same, and the renamed row keeps -// its old name and its definition; -// - control: a create under a free name answers 201. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const SYS = { context: { isSystem: true } } as const; -/** The metadata door's envelope for a save over a name a package or a built-in holds. */ -const DOOR_REFUSAL = { status: 403, code: 'NOT_OVERRIDABLE' }; -/** A position the showcase package declares. */ -const HELD = 'manager'; -/** The built-in audience anchor. */ -const ANCHOR = 'everyone'; - -describe('[ADR-0131 D3] a Setup create or rename into a held position name answers the metadata door\'s refusal over HTTP (showcase)', () => { - let dir: string; - let stack: VerifyStack | undefined; - let token: string; - let ql: any; - - const call = async (method: string, path: string, body?: unknown) => { - const res = await stack!.apiAs(token, method, path, body); - const json: any = await res.json().catch(() => ({})); - return { status: res.status, code: json?.code ?? json?.error?.code ?? null, json }; - }; - const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); - const rowIds = async (name: string) => - ((await ql.find('sys_position', { where: { name }, limit: 10 }, SYS)) as any[]).map((r) => r.id).sort(); - const envRows = async (name: string) => - ((await ql.find('sys_metadata', { where: { type: 'position', name }, limit: 10 }, SYS)) as any[]) - .map((r) => ({ organization_id: r.organization_id ?? null, state: r.state })); - - beforeAll(async () => { - dir = mkdtempSync(join(tmpdir(), 'dogfood-held-position-')); - stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('PRECONDITION: single posture; the showcase package holds the name, and the metadata door refuses a save of it', async () => { - expect(stack!.tenancy().requestedPosture).toBe('single'); - expect((ql.registry.getArtifactItem('position', HELD) as any)?._packageId).toBe('com.example.showcase'); - // [ADR-0131 D2] The package's declaration is the name's one home; the boot writes no row for it. - expect(await rowIds(HELD), 'no row is seeded').toEqual([]); - const put = await call('PUT', `/meta/position/${HELD}`, { name: HELD, label: 'Mine' }); - expect(outcome(put), JSON.stringify(put.json)).toEqual(DOOR_REFUSAL); - }); - - it.each([ - ['a package declares', HELD], - ['the platform declares as an audience anchor', ANCHOR], - ])('a create under a name %s answers the door\'s refusal and keeps no row and no definition', async (_holder, name) => { - const before = await rowIds(name); - const created = await call('POST', '/data/sys_position', { name, label: 'Mine' }); - expect(outcome(created), JSON.stringify(created.json)).toEqual(DOOR_REFUSAL); - expect(await rowIds(name), 'the row the write-through wrote first was undone').toEqual(before); - expect(await envRows(name)).toEqual([]); - }); - - it('a rename into a held name answers the same; the row keeps its name and its definition', async () => { - const created = await call('POST', '/data/sys_position', { name: 'held_e2_lane_lead', label: 'Lane lead' }); - expect(created.status, JSON.stringify(created.json)).toBe(201); - const [id] = await rowIds('held_e2_lane_lead'); - const renamed = await call('PATCH', `/data/sys_position/${id}`, { name: HELD }); - expect(outcome(renamed), JSON.stringify(renamed.json)).toEqual(DOOR_REFUSAL); - expect(await rowIds('held_e2_lane_lead')).toEqual([id]); - expect(await envRows('held_e2_lane_lead')).toEqual([{ organization_id: null, state: 'active' }]); - expect(await envRows(HELD)).toEqual([]); - }); -}); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e184e421ee0..a4d912fdc5e 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -137,13 +137,14 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // An ORG-SCOPED sharing-admin permission set: `manage_sharing` and // deliberately NOT `manage_platform_settings` — the card's exposed class is // precisely the caller who holds the org capability and no platform one. - const psId = 'ps_8158_sharing_admin'; - await ql.insert('sys_permission_set', { - id: psId, + // Saved through the metadata door, the security catalog's one write path + // (ADR-0131 D3): a grant names it, and the resolver reads it from there. + const protocol = (await stack.kernel.getServiceAsync('protocol')) as any; + await protocol.saveMetaItem({ + type: 'permission', name: 'sharing_admin_8158', - label: 'Sharing Administrator (org-scoped)', - system_permissions: JSON.stringify(['manage_sharing']), - }, { context: SYS }); + item: { name: 'sharing_admin_8158', label: 'Sharing Administrator (org-scoped)', objects: {}, systemPermissions: ['manage_sharing'] }, + }); // Real sign-ups: better-auth's own path, through every database hook. await stack.signUp(ORG_LESS_EMAIL, PASSWORD); @@ -186,10 +187,9 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // A platform operator outside the organization: `admin_full_access` held // globally, the grant a platform administrator holds under `single`. await stack.signUp(OPERATOR_EMAIL, PASSWORD); - const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); - expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + expect(ql.registry.getItem('permission', 'admin_full_access'), 'admin_full_access is declared').toBeTruthy(); await ql.insert('sys_user_permission_set', { - user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, + user_id: await uid(OPERATOR_EMAIL), permission_set: 'admin_full_access', organization_id: null, }, { context: SYS }); // Sign in AFTER the grants, so every session is minted by the same path diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 01b6bcf89ac..1738fb568d8 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -343,13 +343,12 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const email = `crudmx-${set.replace(/^showcase_/, '')}@verify.test`; const token = await stack.signUp(email); if (set !== BASELINE_SET) { - const ps = await ql.findOne('sys_permission_set', { where: { name: set }, context: SYS }); - expect(ps?.id, `permission set ${set} seeded by the security bootstrap`).toBeTruthy(); + expect(ql.registry.getItem('permission', set), `permission set ${set} declared by the showcase`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; expect(uid, `persona ${email} provisioned`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', - { user_id: uid, permission_set: ps.name }, + { user_id: uid, permission_set: set }, { context: SYS }, ); } @@ -620,11 +619,12 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { // ── The exclusion is declared, not silent ──────────────────────────────── it('names the matrix rows this file does not judge (the sys_* delegate surface)', () => { - expect(UNJUDGED_ROWS.map((r) => r.object).sort()).toEqual([ + // The two retired catalog row objects (ADR-0131 D3) leave the matrix with + // C3 stage 8; the rest of the delegate surface stays. + const RETIRED = new Set(['sys_permission_set', 'sys_position']); + expect(UNJUDGED_ROWS.map((r) => r.object).filter((o) => !RETIRED.has(o)).sort()).toEqual([ 'sys_business_unit', 'sys_business_unit_member', - 'sys_permission_set', - 'sys_position', 'sys_user', 'sys_user_position', ]); diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 7060c491032..e064a386c7b 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -114,6 +114,8 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = 'showcase_member_default', 'member_default', ]); - expect(await ql.find('sys_position_permission_set', { where: {}, context: SYS }), 'no junction row is written').toEqual([]); + if (ql.getObject?.('sys_position_permission_set')) { + expect(await ql.find('sys_position_permission_set', { where: {}, context: SYS }), 'no junction row is written').toEqual([]); + } }); }); diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index c23377e797c..f63b0355a10 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -46,9 +46,9 @@ // lives only inside this test's stack — the showcase's committed metadata is // untouched, so what the stock app declares is unchanged. // -// Why a private boot rather than the worker-shared showcase: this file inserts a -// `sys_permission_set` row, which the shared harness's eligibility rules exclude -// outright (no permission-set metadata edits — see `shared-showcase.ts`). +// Why a private boot rather than the worker-shared showcase: this file saves a +// permission set, which the shared harness's eligibility rules exclude outright +// (no permission-set metadata edits — see `shared-showcase.ts`). // // ── One thing measured here and deliberately NOT asserted ──────────────────── // @@ -116,32 +116,27 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked // would come back readable and every assertion here would fail as if the // strip were broken. Deactivation is the activation ledger's (ADR-0131 D3); // this set has no ledger row, so it is in effect. - // [ADR-0131 D3/D4] Authored through the data door, as Setup authors it: the - // write-through stores the definition the security catalog resolves. A - // system-context row insert has no definition and grants nothing. - const authored = await stack.apiAs(adminTok, 'POST', '/data/sys_permission_set', { + // [ADR-0131 D3/D4] Authored through the metadata door, the security + // catalog's write path: the definition it stores is what the resolver reads. + const authored = await stack.apiAs(adminTok, 'PUT', `/meta/permission/${SCRATCH_SET}`, { name: SCRATCH_SET, label: 'Scratch: FLS read deny on showcase_project.budget', description: 'Test-authored (#9481). Stock showcase authors no readable:false grant — see #9308.', - object_permissions: JSON.stringify({}), + objects: {}, // Object-QUALIFIED key. A bare `budget` key silently enforces nothing: // the evaluator matches `.` prefixes, which is the exact // defect the permission-zoo audit found on the write half. - field_permissions: JSON.stringify({ + fields: { [`showcase_project.${DENIED}`]: { readable: false, editable: false }, - }), - system_permissions: JSON.stringify([]), - active: true, + }, }); - expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); - const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); - expect(ps?.id, 'scratch permission set authored').toBeTruthy(); + expect(authored.status, 'scratch set authored through the metadata door').toBeLessThan(300); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; expect(memberId, 'member provisioned').toBeTruthy(); await ql.insert( 'sys_user_permission_set', - { user_id: memberId, permission_set: ps.name }, + { user_id: memberId, permission_set: SCRATCH_SET }, { context: SYS }, ); diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts deleted file mode 100644 index 0c88b8c2dbc..00000000000 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// @proof: permission-set-projection -// -// ADR-0094 — `sys_permission_set` is a PURE PROJECTION of the metadata layer, -// proven on the real showcase stack. The record has no independent authority: -// -// 1. A data-door create/edit lands in the METADATA store (write-through) and -// the record is re-derived by the AWAITED projector — consistent on the -// very next read, no race. -// 2. [commit ee58392e1 inverted this pin] A data-door edit of a CODE-DECLARED set is -// REFUSED — `permission` rolled back to `allowOrgOverride: false` -// (ADR-0005 security row: "Authorization correctness; overlays would -// create silent privilege drift"), so overriding an artifact-backed set -// answers 403 `not_overridable` instead of minting an overlay. The -// behaviour before commit ee58392e1 (ADR-0094's 2026-07-14 "customize via env -// overlay" direction) is closed until an ADR-0005 revision readmits the -// type; ADR-0086 two-doors applies meanwhile (edit the package, -// re-publish). -// 3. Deleting a runtime-only set retires its record; deleting an -// artifact-backed set never removes it — the definition ships with the -// app and cannot be removed from the environment. Since commit ee58392e1 there is -// no overlay left to lift, so that "reset" is a no-op success, not the -// revert-to-the-declared-body step ADR-0094's 2026-07-14 direction -// described (retired by D5-R). -// 4. [commit ee58392e1 inverted this pin too] An environment-door metadata save that -// targets a package-owned, artifact-backed set is refused the same way — -// record, provenance and effective body all stay exactly as shipped. -// (Package-bound rows MATERIALIZED through the metadata door carry -// `sys_metadata` provenance, not an artifact, and stay editable through -// the `allowRuntimeCreate` tier — see two-doors-permission 块2.) - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; - -describe('sys_permission_set pure projection (ADR-0094)', () => { - let stack: VerifyStack; - let ql: any; - let protocol: any; - let adminToken: string; - - beforeAll(async () => { - stack = await bootShowcase(); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - protocol = await stack.kernel.getServiceAsync('protocol'); - }, 60_000); - afterAll(async () => { await stack?.stop(); }); - - const findSet = async (name: string) => - (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - const overlayBody = async (name: string) => { - const layered = await protocol.getMetaItemLayered({ type: 'permission', name }); - return layered?.overlay ?? null; - }; - - // ── 1. Data-door create → metadata store + awaited projection ───────────── - it('a data-door create lands in metadata and the record is projected before the response returns', async () => { - const NAME = 'proj_probe_agent'; - const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: NAME, - label: 'Projection Probe', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - system_permissions: JSON.stringify(['probe.use']), - }); - expect(res.status).toBeLessThan(300); - - // The record exists immediately (awaited projection — no poll/sleep) and is - // env-owned, not forged package provenance. - const row = await findSet(NAME); - expect(row, 'record projected synchronously with the create').toBeTruthy(); - expect(row.managed_by).toBe('admin'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - - // …and the authoritative store is the metadata overlay, not the row. - const overlay = await overlayBody(NAME); - expect(overlay?.name, 'the definition lives in the metadata store').toBe(NAME); - expect(overlay.systemPermissions).toEqual(['probe.use']); - }); - - it('a data-door edit updates metadata and the record follows in lock-step', async () => { - const NAME = 'proj_probe_agent'; - const row = await findSet(NAME); - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, { - system_permissions: JSON.stringify(['probe.use', 'probe.admin']), - }); - expect(res.status).toBeLessThan(300); - - expect((await overlayBody(NAME)).systemPermissions).toEqual(['probe.use', 'probe.admin']); - expect(JSON.parse((await findSet(NAME)).system_permissions)).toEqual(['probe.use', 'probe.admin']); - }); - - it('deleting a runtime-only set retires both the definition and the record', async () => { - const NAME = 'proj_probe_agent'; - const row = await findSet(NAME); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${row.id}`); - expect(res.status).toBeLessThan(300); - expect(await findSet(NAME), 'runtime-only record retired on delete').toBeFalsy(); - expect(await overlayBody(NAME), 'metadata overlay gone too').toBeFalsy(); - }); - - // ── 2. Data-door edit of a DECLARED set is REFUSED (commit ee58392e1) ───── - it('editing a declared set through the data door is refused — no overlay is minted', async () => { - // member_default is a platform-declared set (an artifact baseline - // exists), so the write-through's `saveMetaItem` hits the ADR-0005 type - // gate: `permission` is no longer `allowOrgOverride` (commit ee58392e1, the - // security row's "silent privilege drift"). The refusal must be LOUD — - // an error status, not a 2xx that quietly skipped the metadata write — - // and must leave no overlay behind (#6190's phantom-write shape is the - // thing the rollback forbids). - expect(await overlayBody('member_default'), 'no overlay before the edit').toBeFalsy(); - const md = await findSet('member_default'); - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${md.id}`, { - description: 'customized via Setup (ADR-0094)', - }); - expect(res.status, 'the refusal surfaces to the data-door caller').toBe(403); - - // Nothing was minted and nothing enforces differently: no overlay row, - // record description unchanged. - expect(await overlayBody('member_default'), 'no overlay after the refusal either').toBeFalsy(); - expect((await findSet('member_default')).description ?? null).not.toBe('customized via Setup (ADR-0094)'); - }); - - // ── 3. Delete of an artifact-backed set RESETS (does not remove) ────────── - it('deleting a declared set through the data door resets it to the declared body, keeping the record', async () => { - // (commit ee58392e1: with the edit above refused, there is no overlay to lift — the - // delete is a no-op reset. The invariant it pins is unchanged: a - // declared definition cannot be removed from the environment.) - const before = await findSet('member_default'); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); - expect(res.status).toBeLessThan(300); - // [commit f9e16d856] The status cannot tell this reset apart from a real deletion — - // the whole envelope used to be byte-identical to one, so every assertion - // below stayed green while the door told the caller the set was gone. - // `success: false` is the one field that says the record is still here, - // and this is the end-to-end pin on it. - expect((await res.json())?.success, 'a reset does not report a deletion').toBe(false); - - const after = await findSet('member_default'); - expect(after, 'a packaged/declared set cannot be removed from the environment').toBeTruthy(); - // No overlay (none could be created) and no customized description. - expect(await overlayBody('member_default')).toBeFalsy(); - expect(after.description ?? null).not.toBe('customized via Setup (ADR-0094)'); - }); - - // ── 4. Env overlay of a PACKAGE set is REFUSED (commit ee58392e1) ───────── - it('an environment-door metadata save on a package-owned set is refused and changes nothing', async () => { - const contributor = await findSet('showcase_contributor'); - expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); - const layeredBefore = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - const baseline = layeredBefore?.code ?? null; - expect(baseline, 'the packaged declaration is the code layer').toBeTruthy(); - - // Commit ee58392e1 — `permission` rolled back to `allowOrgOverride: false` - // (ADR-0005 security row). The overlay ADR-0094's 2026-07-14 direction - // used here is exactly the per-org shadowing of a code-shipped - // authorization contract the ADR forbids, so the save refuses LOUDLY at - // the moment of the write instead of minting an enforced overlay. - await expect( - protocol.saveMetaItem({ - type: 'permission', - name: 'showcase_contributor', - item: { ...baseline, label: 'Contributor (env customized)' }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - // Nothing moved: record, provenance, customized flag and the effective - // body all stay exactly as shipped. - const after = await findSet('showcase_contributor'); - expect(after.label).toBe(contributor.label); - expect(after.managed_by).toBe('package'); - expect(after.package_id).toBe(contributor.package_id); - expect(after.customized).toBe(false); - const layeredAfter = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layeredAfter?.overlay ?? null, 'no overlay row was minted').toBeFalsy(); - }); - - it('a brand-new environment set authored through the metadata door appears as a Setup record', async () => { - const NAME = 'proj_env_authored'; - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - item: { name: NAME, label: 'Env Authored', objects: { crm_lead: { allowRead: true } }, systemPermissions: ['env.use'] }, - }); - // Studio-authored env sets now surface in Setup (the record is created by - // the projector, not left invisible as before ADR-0094). - const row = await findSet(NAME); - expect(row, 'Studio-authored env set appears in Setup').toBeTruthy(); - expect(row.managed_by).toBe('admin'); - expect(JSON.parse(row.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); -}); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index b7268aebcbf..0c1fd7385f8 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -129,32 +129,10 @@ describe('showcase: ADR-0090 permission-model zoo', () => { expect(names, 'the granting set is attributed').toContain('showcase_auditor'); }); - /** - * [ADR-0131 D2] No boot writes a `sys_position` row. The junction-write - * gates below judge a write naming a ROW, so each case reads the row an - * existing database holds — what the retired seeders wrote — and stages it - * when this (shared) stack has none yet. - */ - const existingPositionRow = async (name: string, managedBy: 'platform' | 'package') => { - const found = await ql.findOne('sys_position', { where: { name }, context: SYS }); - if (found) return found; - await ql.insert('sys_position', { id: `pos_zoo_${name}`, name, label: name, managed_by: managedBy }, { context: SYS }); - return ql.findOne('sys_position', { where: { name }, context: SYS }); - }; - // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { - // Bind contributor → showcase_contributor first (admin action; the - // delegate has manageBindings: false and must NOT be able to do this). - const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(pos?.id && ps?.id, 'contributor position + set rows').toBeTruthy(); - const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: pos.id, - permission_set_id: ps.id, - }); - expect(bind.status, 'tenant admin binds set to position').toBeLessThan(300); - + // [ADR-0131 D3/D4] `contributor` binds `showcase_contributor` on its own + // definition (`permissionSets`), which the allowlist judges; no binding row. const assign = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { user_id: targetId, position: 'contributor', @@ -188,38 +166,11 @@ describe('showcase: ADR-0090 permission-model zoo', () => { expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); }); - it('the delegate cannot re-compose positions (manageBindings: false)', async () => { - const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); - const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { - position_id: pos.id, - permission_set_id: ps.id, - }); - expect(r.status, 'binding writes need manageBindings').not.toBeLessThan(300); - }); - - // ── ADR-0090 D5/D9: the audience-anchor binding gate ───────────────────── - it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { - const everyone = await existingPositionRow('everyone', 'platform'); - expect(everyone, 'built-in everyone position row').toBeTruthy(); - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); - const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: everyone.id, - permission_set_id: auditor.id, - }); - expect(r.status, 'viewAllRecords on the everyone anchor is lint-tier blocked at runtime').not.toBeLessThan(300); - }); - - it('binding the guest-safe set to the guest anchor is accepted', async () => { - const guest = await existingPositionRow('guest', 'platform'); - expect(guest, 'built-in guest position row').toBeTruthy(); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); - const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { - position_id: guest.id, - permission_set_id: ps.id, - }); - expect(r.status, 'read-only + create-intake set passes the guest tier').toBeLessThan(300); - }); + // [C3 stage 8] The junction-write gates this file pinned here — a delegate + // re-composing a position, a high-privilege set bound to the `everyone` + // anchor, the guest-safe set bound to `guest` — judged rows of the retired + // `sys_position_permission_set` object (ADR-0131 D3). A position's sets are + // its definition's `permissionSets`, written through the metadata door. // ── FLS enforcement — object-qualified keys (permission-zoo audit) ─────── // The contributor set masks the project budget figures read-only. The keys diff --git a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts deleted file mode 100644 index a7178dcdd40..00000000000 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// ADR-0086 P2 — "two doors" separation for permission sets, proven on the real -// showcase stack (which declares `showcase_contributor` as a package set): -// -// 块1 — the PACKAGE door: a permission set authored as a `permission` metadata -// draft under a package and then PUBLISHED is materialized into -// `sys_permission_set` with `managed_by:'package'` + the owning -// `package_id` (publish-time, not just at boot). A draft alone -// materializes nothing — only publish makes it live. -// -// 块2 — the ADMIN door (evolved by ADR-0094; its 2026-07-14 "translate the -// edit into an env-scope ADR-0005 overlay" direction was RETIRED on -// 2026-08-09 — see ADR-0094 D5-R): a data-plane edit of a -// package-managed, ARTIFACT-BACKED row is REFUSED with 403 -// `not_overridable` and no overlay is minted — commit ee58392e1 rolled -// `permission` back to `allowOrgOverride: false`, and ADR-0086 names -// the supported channel instead: edit the package and re-publish. -// A "delete" through this door still degrades to a RESET — a packaged -// definition can never be removed from the environment — though with -// no overlay left to lift there is nothing for it to revert. A set -// authored through the data door rides the still-open -// `allowRuntimeCreate` tier and stays editable. -// Forging package provenance through the admin door stays refused. - -import { describe, it, expect, beforeAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { getSharedShowcase } from './shared-showcase.js'; - -describe('two-doors permission separation (ADR-0086 P2)', () => { - let stack: VerifyStack; - let ql: any; - let protocol: any; - let adminToken: string; - - beforeAll(async () => { - stack = await getSharedShowcase(); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - protocol = await stack.kernel.getServiceAsync('protocol'); - }, 60_000); - - const findSet = async (name: string) => - (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - - // ── 块1 — package door: draft → publish → materialize ──────────────────── - it('块1: publishing a package permission draft materializes a package-managed row', async () => { - // An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage), - // standing in for the package the studio package door edits. - const PKG = 'com.example.twodoors_ws'; - const NAME = 'twodoors_pkgset'; - - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - mode: 'draft', - packageId: PKG, - item: { - name: NAME, - label: 'Two Doors Set', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - }, - }); - - // Draft only — enforcement/admin-surface must NOT see it yet. - expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy(); - - const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME }); - expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy(); - expect(pub.materializeApplied.success).toBe(true); - - const row = await findSet(NAME); - expect(row, 'published set is now a real record').toBeTruthy(); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe(PKG); - expect(JSON.parse(row.object_permissions || '{}')).toEqual({ - crm_lead: { allowRead: true, allowCreate: true }, - }); - }); - - // ── 块2 — admin door: artifact-backed rows now REFUSE the overlay (commit ee58392e1) ─ - it('块2: an admin edit of a package-managed set is refused — no env overlay is minted (#6483)', async () => { - // Before commit ee58392e1 this edit became an env-scope overlay (ADR-0094's 2026-07-14 - // direction). `permission` has since rolled back to - // `allowOrgOverride: false` (ADR-0005 security row: "Authorization - // correctness; overlays would create silent privilege drift"), so the - // write-through's `saveMetaItem` refuses the artifact-backed target with - // 403 `not_overridable` — loudly, at the moment of the write. ADR-0086 - // two-doors names the supported channel: edit the package, re-publish. - const contributor = await findSet('showcase_contributor'); - expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${contributor.id}`, { - label: 'Contributor (customized)', - }); - expect(res.status, 'the refusal surfaces to the data-door caller').toBe(403); - - // Nothing was minted and nothing projects differently. - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layered?.overlay ?? null, 'no env-scope overlay row exists').toBeFalsy(); - const after = await findSet('showcase_contributor'); - expect(after.label).toBe(contributor.label); - expect(after.managed_by).toBe('package'); - expect(after.package_id).toBe(contributor.package_id); - }); - - it('块2: "deleting" the package set through the env door still RESETS to the shipped declaration', async () => { - // (commit ee58392e1: with the edit above refused there is no overlay to lift, but - // the invariant is unchanged and still pinned: the env door can never - // remove a packaged definition — delete degrades to reset.) - const before = await findSet('showcase_contributor'); - const res = await stack.apiAs(adminToken, 'DELETE', `/data/sys_permission_set/${before.id}`); - expect(res.status).toBeLessThan(300); - // [commit f9e16d856] The status cannot tell this reset apart from a real deletion — - // the whole envelope used to be byte-identical to one, so every assertion - // below stayed green while the door told the caller the set was gone. - // `success: false` is the one field that says the record is still here, - // and this is the end-to-end pin on it. - expect((await res.json())?.success, 'a reset does not report a deletion').toBe(false); - - const after = await findSet('showcase_contributor'); - expect(after, 'a packaged definition is never removed by the env door').toBeTruthy(); - expect(after.label, 'label reset to the shipped declaration').not.toBe('Contributor (customized)'); - expect(after.managed_by).toBe('package'); - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'showcase_contributor' }); - expect(layered?.overlay, 'overlay gone after the reset').toBeFalsy(); - }); - - it('块2: the admin door CAN still edit an env-authored set (isolates the gate to artifact-backed rows)', async () => { - // The specimen was `member_default` until commit ee58392e1: its record is env-owned - // (`managed_by` ≠ 'package'), but its METADATA identity is a platform - // artifact (`defaultPermissionSets`), so with `permission` rolled back - // to `allowOrgOverride: false` the write-through's `saveMetaItem` now - // refuses to overlay it — the record's provenance column was never what - // the gate reads. A truly env-AUTHORED set (created through the data - // door, definition living only in `sys_metadata`) rides the - // `allowRuntimeCreate` tier, which commit ee58392e1 deliberately left open — that - // is the boundary this case isolates. - const NAME = 'twodoors_env_authored'; - const created = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: NAME, - label: 'Env Authored (two doors)', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - }); - expect(created.status).toBeLessThan(300); - const row = await findSet(NAME); - expect(row?.managed_by, 'env-authored set is admin-owned').toBe('admin'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${row.id}`, { - description: 'edited through the admin door', - }); - expect(res.status).toBeLessThan(300); - - const after = await findSet(NAME); - expect(after.description).toBe('edited through the admin door'); - }); - - it('块2: the admin door can NO LONGER edit an artifact-backed set (#6483 — the gate reads artifact provenance)', async () => { - // The other half of the boundary: `member_default`'s definition ships as - // a platform artifact, so the same PATCH that works on an env-authored - // set answers 403 `not_overridable` here — loudly, at the moment of the - // write, with no overlay minted (ADR-0005 security row; ADR-0086 - // two-doors names the supported channel: edit the package, re-publish). - const memberDefault = await findSet('member_default'); - expect(memberDefault?.managed_by ?? null, 'member_default record is env-owned').not.toBe('package'); - - const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${memberDefault.id}`, { - description: 'edited through the admin door', - }); - expect(res.status, 'artifact-backed set refuses the data-door edit').toBe(403); - - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: 'member_default' }); - expect(layered?.overlay ?? null, 'no overlay row was minted by the refused edit').toBeFalsy(); - }); - - it('块2: the admin door cannot forge package provenance on insert', async () => { - const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', { - name: 'forged_pkg_set', - label: 'Forged', - managed_by: 'package', - package_id: 'com.example.twodoors_ws', - }); - expect(res.status).toBe(403); - expect(await findSet('forged_pkg_set'), 'the forged row must not exist').toBeFalsy(); - }); -}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 825b6552165..0516660b120 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -75,7 +75,6 @@ const SHARED_SHOWCASE = [ 'test/showcase-readonly-when-parent.dogfood.test.ts', 'test/showcase-search.dogfood.test.ts', 'test/showcase-static-readonly.dogfood.test.ts', - 'test/two-doors-permission.dogfood.test.ts', ]; // Commit 08f5f0e5a / #17978 — say so when a path named on the command line will run no diff --git a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts index 011de0d05e8..0097f6c1d3f 100644 --- a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts +++ b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts @@ -110,8 +110,10 @@ const expectRegimeC = (message: unknown, type: string, name: string, operation: }; const ACTION_SWITCH = 'Switch it off (POST /api/v1/actions/_activation/:object/:action, body {enabled: false}'; +// The route's spelling moved with C3 stage 8 (the retired object's data route +// → the catalog's clone door), so the sentence is pinned up to it. const PERMISSION_CLONE = 'Clone it under a new name to customize it (the "Clone" action on the permission set, ' - + 'or POST /api/v1/data/sys_permission_set with a new name).'; + + 'or POST /api/v1/'; describe('PUT / DELETE /api/v1/meta/action/:name on a packaged action — the refusal names the activation switch', () => { it('PUT answers 403 NOT_OVERRIDABLE naming the switch, no clone, no hatch', async () => { diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a33e457181a..da88c4b875b 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -191,6 +191,7 @@ interface ObjectQlRegistrySlot { /** The private field `plugin-sharing`'s `readDeclared` spells instead. */ readonly _registry: unknown; find(object: string, query: Record): Promise; + getObject?(object: string): unknown; } interface MetadataListSlot { @@ -258,7 +259,8 @@ beforeAll(async () => { for (const t of ['sys_permission_set', 'sys_capability', 'sys_sharing_rule']) { // The seeded rows as the platform reads them: the explicit system opt-in. // A context-less read is refused by the security middleware (ADR-0096 D5). - rows[t] = await ql.find(t, { context: { isSystem: true } }); + // [C3 stage 8] A retired catalog row object has no table, and no rows. + rows[t] = ql.getObject?.(t) ? await ql.find(t, { context: { isSystem: true } }) : []; } }, BOOT_TIMEOUT); @@ -383,34 +385,9 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(door).toMatchObject({ isDefault: false, _packageVersion: '3.0.0' }); }); - it('⚠️ NOT ABSORBED — `sys_permission_set` persists the UN-PARSED registry copy, byte for byte', () => { - const reg = byName(registryCopy.permission, 'probe_agent'); - const door = byName(doorCopy.permission, 'probe_agent'); - const row = byName(rows.sys_permission_set, 'probe_agent'); - - // The decisive read: the stored authorization map is deep-equal to the - // registry copy's and NOT to the door's. Nothing between `readDeclared` and - // `tryInsert` re-parses it — `permissionSetRowFields` serialises what it - // was handed. - const storedObjects = JSON.parse(row.object_permissions); - expect(storedObjects).toEqual(reg.objects); - expect(storedObjects).not.toEqual(door.objects); - - const storedRls = JSON.parse(row.row_level_security); - expect(storedRls).toEqual(reg.rowLevelSecurity); - expect(storedRls).not.toEqual(door.rowLevelSecurity); - - // Spelled out key by key, so a future change to either side names itself. - expect(storedObjects.crm_ticket).toMatchObject({ allowRestore: true, allowPurge: false }); - expect(storedObjects.crm_ticket).not.toHaveProperty('allowTransfer'); - expect(storedObjects.crm_ticket).not.toHaveProperty('viewAllRecords'); - expect(storedObjects.crm_ticket).not.toHaveProperty('modifyAllRecords'); - expect(storedRls[0]).toMatchObject({ priority: 10 }); - - expect(row).toMatchObject({ - managed_by: 'package', package_id: 'com.test.issue-14491', active: true, customized: false, - }); - }); + // [ADR-0131 D3] Which copy a `sys_permission_set` row persisted is no longer + // a question: no boot writes that row (C3 stage 6b-2), and the object itself + // is retired (C3 stage 8). The registry copy is what the catalog serves. it('`sharing_rule`: the registry copy keeps the legacy `sharedWith.type` and `accessLevel` the door forward-converts', () => { const regLegacy = byName(registryCopy.sharing_rule, 'share_legacy_deals'); diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 6fb3bacd855..2e078438f1c 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -29,8 +29,6 @@ describe('SystemObjectName', () => { expect(SystemObjectName.DEVICE_CODE).toBe('sys_device_code'); expect(SystemObjectName.JWKS).toBe('sys_jwks'); expect(SystemObjectName.USER_PREFERENCE).toBe('sys_user_preference'); - expect(SystemObjectName.POSITION).toBe('sys_position'); - expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set'); expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log'); expect(SystemObjectName.METADATA).toBe('sys_metadata'); expect(SystemObjectName.PRESENCE).toBe('sys_presence'); @@ -43,7 +41,6 @@ describe('SystemObjectName', () => { expect(names).toContain('sys_organization'); expect(names).toContain('sys_team'); expect(names).toContain('sys_team_member'); - expect(names).toContain('sys_position'); expect(names).toContain('sys_audit_log'); expect(names).toContain('sys_presence'); }); @@ -62,8 +59,6 @@ describe('SystemObjectName', () => { expect(keys).toContain('API_KEY'); expect(keys).toContain('TWO_FACTOR'); expect(keys).toContain('USER_PREFERENCE'); - expect(keys).toContain('POSITION'); - expect(keys).toContain('PERMISSION_SET'); expect(keys).toContain('AUDIT_LOG'); expect(keys).toContain('METADATA'); expect(keys).toContain('PRESENCE'); From 9d55c0589a197294abfd535183bb49db4a7bd944 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:45:56 +0000 Subject: [PATCH 2/8] test(plugin-security): the uninstall-cleanup suite hands the sweep D's declared names shape D (#22900) landed the uninstall cleanup's declared names as `UninstallDeclared` ({ permission: string[] }), not the bare name array the stage-8 branch was built against. The suite now passes that shape and drops its signature cast; D's two named-grant pins are restated over S8-T's fixture so each holds whether the sweep also reaches the package's catalog rows (main) or reaches its sets by the declared names alone (after C3 stage 8). The runner suite's comment attributes `declared` to the names D captures. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- ...protocol.uninstall-cleanups-runner.test.ts | 4 +- .../src/cleanup-package-permissions.test.ts | 40 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts index 409da59850b..c4e0707d7dc 100644 --- a/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts +++ b/packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts @@ -99,8 +99,8 @@ describe('#21490: runUninstallCleanups — the uninstall-cleanup registry\'s one const res = await protocol.deletePackage(request); - // Its own request — plus, since C3 stage 8, the catalog items the package - // declared (`declared`), read before its withdrawal. + // Its own request — plus, when the package declared permission sets, their + // names (`declared`, ADR-0131 D4), read before its withdrawal. expect(runner.mock.calls).toEqual([[expect.objectContaining(request)]]); expect(res.cleanups).toEqual(await runner.mock.results[0]!.value); expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 4 }]); diff --git a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts index 75db312aa7d..3895d8f8053 100644 --- a/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts +++ b/packages/plugins/plugin-security/src/cleanup-package-permissions.test.ts @@ -3,23 +3,15 @@ // (ADR-0086 D3 provenance consumed at last; ADR-0090 D5 "no ghost grants"). import { describe, it, expect } from 'vitest'; -import { cleanupPackagePermissions as cleanup } from './cleanup-package-permissions'; +import { cleanupPackagePermissions } from './cleanup-package-permissions'; /** - * The sweep, with the declared-names argument C3 stage 8 adds. Spelled through - * this signature so the suite compiles against the module before and after - * that stage: before it, the sweep finds the package's sets by its catalog - * rows and ignores the names; after it, it finds them by the names. + * The permission-set names the uninstalling door read off the package before + * its withdrawal (`UninstallDeclared`, ADR-0131 D4). Handed to the sweep, they + * revoke the same grants whether it also finds the package's sets by their + * catalog rows (before C3 stage 8) or by these names alone (after it). */ -const cleanupPackagePermissions = cleanup as ( - ql: any, - packageId: string, - logger?: Parameters[2], - declaredSetNames?: readonly string[], -) => ReturnType; - -/** The permission-set names the uninstalled package declared (read off it before its withdrawal). */ -const DECLARED = ['crm_readonly', 'crm_admin']; +const DECLARED = { permission: ['crm_readonly', 'crm_admin'] }; /** * A store holding both shapes a grant has carried: every grant names its set @@ -188,17 +180,25 @@ describe('[ADR-0131 D4, ADR-0090 D5] the grants naming a set the package declare it('revokes every user grant naming a declared set, and only those', async () => { const ql = withNamedGrants(); - const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { permission: ['crm_catalog_user'] }); + const out = await cleanupPackagePermissions(ql, 'com.example.crm', undefined, { + permission: [...DECLARED.permission, 'crm_catalog_user'], + }); - expect(out.userGrants).toBe(3); // the legacy row's grant (ups_1) and the two naming the declared set - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_other']); + // The two grants naming the package's row-backed sets and the two naming + // its registry-only set. + expect(out.userGrants).toBe(4); + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_other', 'ups_named_other']); }); - it('control — handed no names, a grant naming a catalog set survives: the cleanup is as it was', async () => { + it('control — handed no names, a grant naming a registry-only set survives', async () => { const ql = withNamedGrants(); const out = await cleanupPackagePermissions(ql, 'com.example.crm'); - expect(out).toEqual({ sets: 2, positionBindings: 2, userGrants: 1, suggestions: 2, refused: 0 }); - expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual(['ups_env', 'ups_named_1', 'ups_named_2', 'ups_named_other']); + expect(out).toMatchObject({ suggestions: 2, refused: 0 }); + // Whatever the row legs reach while the catalog rows still exist, no grant + // naming a set the sweep was not handed is revoked. + expect(ql.tables.sys_user_permission_set.map((r: any) => r.id)).toEqual( + expect.arrayContaining(['ups_env', 'ups_other', 'ups_named_1', 'ups_named_2', 'ups_named_other']), + ); }); }); From 008ca32a75b74ebb16f26c420c6eeac6d54ab609 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:12:49 +0000 Subject: [PATCH 3/8] test: suites main brought in since S8-T was built stop depending on the four catalog row objects Three files that reached main after S8-T's base read the retired objects: - walled-catalog-data-door.test.ts is deleted: it pins the walled catalog refusal's data-door registration on sys_position, sys_permission_set and sys_capability, which stage 8 unregisters with the write-throughs (the precedent of position-write-through.test.ts); - wall-catalog-refusal's data-door case answers 403 PERMISSION_DENIED while the row objects are registered and 404 OBJECT_NOT_FOUND once they are retired, and asserts no definition either way; - mail-message-pool-read-gate reads its fixture sets from the security catalog: no boot has written a sys_permission_set row since D, so the row read failed on main as well. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/walled-catalog-data-door.test.ts | 227 ------------------ ...ail-message-pool-read-gate.dogfood.test.ts | 7 +- .../test/wall-catalog-refusal.dogfood.test.ts | 25 +- 3 files changed, 24 insertions(+), 235 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts deleted file mode 100644 index 003a885f3f6..00000000000 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the - * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). - * - * Measured before it on this same harness shape (a real `ObjectQL` over a real - * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the - * organization-scoping service): an organization-bound administrator holding - * the superuser wildcard created a position at the data door, and it landed - * as a row of that organization. Nothing judged a catalog create by posture. - * - * Pinned here, per posture: - * - `isolated` and `group`: the tenant's create, update and delete of a - * position, a permission set and a capability are refused with the ADR-0112 - * envelope `403 PERMISSION_DENIED`, and nothing is stored; - * - the CONTROL: the same tenant still assigns a catalog position to a member - * of its organization; - * - the platform operator rung and a system write are admitted; - * - `single`: the same tenant's create is accepted, unchanged. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { resolveThrownHttpError } from '@objectstack/types'; -import type { PermissionSet } from '@objectstack/spec/security'; - -import { SecurityPlugin } from './security-plugin.js'; -import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { SysMetadataActivation } from '@objectstack/platform-objects/system'; -import { SysPosition } from './objects/sys-position.object.js'; -import { SysUserPosition } from './objects/sys-user-position.object.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { SysCapability } from './objects/sys-capability.object.js'; -import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; - -const ORG = 'org_a'; - -/** The fallback every authenticated caller resolves: it grants nothing here. */ -const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; - -/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -type Posture = 'single' | 'isolated' | 'group'; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(posture: Posture) { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.walled-catalog-data-door', - name: 'Walled catalog data door', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, - SysCapability, SysMember, SysMetadataActivation, - ], - } as any); - await engine.syncSchemas(); - for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { - if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); - } - engines.push(engine); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata: { - get: async (type: string, name: string) => - type === 'permission' - ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null - : engine.getSchema(name) ?? null, - list: async () => [MEMBER_DEFAULT, QA_ADMIN], - }, - ...(posture === 'single' - ? {} - : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), - }; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - hook: vi.fn(), - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - const walled = posture !== 'single'; - const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; - // The catalog the tenant may assign: one position, declared in the registry. - engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, - 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); - // Rows the tenant will try to change: one per catalog object. - await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); - await engine.insert('sys_permission_set', { - id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', - object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, - }, { context: system } as any); - await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); - // The member an assignment names: a member of the organization it is scoped to. - await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, - { context: { isSystem: true, tenantId: ORG } } as any); - - const tenant = walled - ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } - : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; - return { engine, tenant, system }; -} - -async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { - try { - await run; - return { ok: true }; - } catch (e) { - const r = resolveThrownHttpError(e); - return { ok: false, code: r.code, status: r.status }; - } -} - -const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; - -async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); - return Array.isArray(rows) ? rows.length : 0; -} - -for (const posture of ['isolated', 'group'] as const) { - describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { - it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); - expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); - expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); - }); - - it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) - .toEqual(REFUSED); - expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) - .toEqual(REFUSED); - const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; - expect(row?.label).toBe('Existing'); - expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); - }); - - it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { - const { engine, tenant } = await boot(posture); - expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) - .toEqual({ ok: true }); - }); - - it('the platform operator rung and a system write are admitted', async () => { - const { engine, tenant, system } = await boot(posture); - const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) - .toEqual({ ok: true }); - }); - - it('a tenant rung below the operator is refused like a context that carries none', async () => { - const { engine, tenant } = await boot(posture); - const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; - expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) - .toEqual(REFUSED); - }); - }); -} - -describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { - it('the same administrator creates a position and a permission set', async () => { - const { engine, tenant } = await boot('single'); - expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) - .toEqual({ ok: true }); - expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); - }); -}); - -describe('the judged objects', () => { - it('are exactly the three security catalog objects — assignment objects are never judged', () => { - expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ - sys_position: 'position', - sys_permission_set: 'permission', - sys_capability: 'capability', - }); - }); -}); diff --git a/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts index 0c9642cea00..adb8b0fa730 100644 --- a/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/mail-message-pool-read-gate.dogfood.test.ts @@ -38,6 +38,7 @@ import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ const CAP = 'manage_mailboxes'; @@ -107,8 +108,10 @@ describe('[#22870] the inbound-mail pool is restricted to mailbox owners and adm const userIdOf = async (email: string) => String((await atRest('sys_user', { email }))[0]?.id ?? ''); uid.admin = await userIdOf('admin@objectos.ai'); const grant = async (who: Who, setName: string) => { - const [set] = await atRest('sys_permission_set', { name: setName }); - expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + // [ADR-0131 D3] A set is served by name from the security catalog; no + // boot writes a `sys_permission_set` row for it. + const set = await catalogPermissionSet(ql, setName); + expect(set, `fixture permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: set.name }, { context: { ...SYS } }); }; for (const who of ['reader', 'owner', 'holder', 'outsider'] as const) { diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 2f38d83196e..54813c3396a 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -22,8 +22,10 @@ // Pinned here: // - walled: org A's catalog writes at `/meta` (save, create, delete) answer // `403 FORBIDDEN`, and B's resolution does not move; -// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, -// and nothing reaches the environment ledger; +// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED` +// while the catalog row objects are registered, and `404 OBJECT_NOT_FOUND` +// once C3 stage 8 retires them; either way nothing reaches the environment +// ledger; // - walled: the permission-set clone door refuses org A a clone // `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own @@ -47,6 +49,7 @@ const SYS = { isSystem: true, positions: [], permissions: [] }; interface Engine { find(object: string, options?: unknown): Promise>>; insert(object: string, data: Record, options?: unknown): Promise; + getObject?(object: string): unknown; } /** The ADR-0112 envelope a refusal answered: `error.code` and the HTTP status. */ @@ -169,14 +172,24 @@ for (const posture of ['isolated', 'group'] as const) { expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); }); - it('the data door refuses org A a catalog create 403 PERMISSION_DENIED, and nothing reaches the environment ledger', async () => { + it('the data door refuses org A a catalog create, and nothing reaches the environment ledger', async () => { + // While the catalog row objects are registered the walled data-door + // refusal answers; once C3 stage 8 retires them (ADR-0131 D3) the data + // door serves no such object at all. + const refusal = (object: string) => (ql.getObject?.(object) + ? { status: 403, code: 'PERMISSION_DENIED' } + : { status: 404, code: 'OBJECT_NOT_FOUND' }); + const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); - expect(await envelopeOf(set)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(await envelopeOf(set)).toEqual(refusal('sys_permission_set')); expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); - expect(await envelopeOf(position)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); - expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + expect(await envelopeOf(position)).toEqual(refusal('sys_position')); + expect((await definitionOf('position', 'w_dd_pos'))?.item).toBeFalsy(); + if (ql.getObject?.('sys_position')) { + expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + } }); it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { From c0dc292ae8128739a1ad026bdac654ee665a7ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:51:10 +0000 Subject: [PATCH 4/8] test: the suites main's D and wall changes touched span stage 8 as well Measured on a local main + S8-T + S8 tree: - plugin-auth's two grant-reader suites seed the legacy platform-bucket rows only where sys_permission_set is still registered; - builtin-positions expects the walled tenant create refusal while the plugin declares the sys_position row object, which stage 8 retires together with that refusal; - the record-page label-map floor is 38, the count with B2's two maps and the retired position page gone. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../cli/test/platform-page-i18n-parity.test.ts | 2 +- .../src/grant-readers-by-name.golden.test.ts | 5 +++-- .../src/grant-readers-unnamed-grant.test.ts | 7 +++++-- .../src/builtin-positions.boot.test.ts | 16 ++++++++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 95b0bd6a671..3f38f799385 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -842,7 +842,7 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { // that the pages went monolingual. [#15204 B2] The user page's two grant // lists dropped their `add` pickers, and with them two label maps. // (`sys_position_detail` retired with the `sys_position` object, C3 stage 8.) - expect(maps.length).toBeGreaterThanOrEqual(39); + expect(maps.length).toBeGreaterThanOrEqual(38); const incomplete = maps .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 2015e096873..ffbd50b6fbe 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -154,8 +154,9 @@ async function standingByReader(posture: Posture): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } - // An existing database: the platform-bucket rows an earlier release seeded. - await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); + // An existing database: the platform-bucket rows an earlier release seeded, + // where the row object is still registered (C3 stage 8 retires it). + if (engine.getObject?.('sys_permission_set')) { + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); + } const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine }; diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index c7c467362df..b16e0290c1f 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -573,15 +573,23 @@ const REFUSAL_GOLDEN: Record = { }, }; +/** + * Whether the plugin still declares the `sys_position` row object. C3 stage 8 + * retires it (ADR-0131 D3) and unregisters the walled data-door refusal with + * it; this harness registers the legacy table itself either way. + */ +const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); + /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused (`walled-catalog-data-door.ts`): the catalog is the platform - * operator's, and a tenant may only assign it. The assignment cases do not - * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. + * refused (`walled-catalog-data-door.ts`) while the plugin declares the row + * object: the catalog is the platform operator's, and a tenant may only assign + * it. The assignment cases do not move, and the built-in row refusals keep + * their `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const golden: Record = scenario.posture === 'single' + const golden: Record = scenario.posture === 'single' || !PLUGIN_DECLARES_POSITION_ROWS ? REFUSAL_GOLDEN : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; if (scenario.existingRows) return golden; From d426f922eda9787b28bea0274138bb3fecb84ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:46:11 +0000 Subject: [PATCH 5/8] test(plugin-security): the walled builtin-positions golden leaves the data-door create out Seat ruling: the walled scenarios no longer judge a tenant's create of the hand-registered legacy sys_position row. That data-door refusal retires with the object in stage 8, as walled-catalog-data-door.test.ts did, and the walled create refusal is pinned at the /meta wall (wall-catalog-refusal.dogfood.test.ts). Replaces the previous commit's declares-the-object switch; every other key of the golden is unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/builtin-positions.boot.test.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index b16e0290c1f..a201c11965e 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,7 +403,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, walled: boolean): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; @@ -412,13 +412,17 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean): Pr }; const everyone = await own('everyone'); const platformAdmin = await own('platform_admin'); + const dangling = await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, + )); + const created = await outcome(engine.insert( + 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, + )); return { - 'assign a position the security catalog does not hold': await outcome(engine.insert( - 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, - )), - 'control: create a position row': await outcome(engine.insert( - 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, - )), + 'assign a position the security catalog does not hold': dangling, + // Under a wall the tenant's create is not judged here: the walled create + // refusal is pinned at the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). + ...(walled ? {} : { 'control: create a position row': created }), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -529,7 +533,8 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { // A fresh database holds no built-in row to delete or relabel; the two // refusals are read where the rows are, on an existing database. const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows)).toEqual(refusalGoldenFor(scenario)); + expect(await tenantPositionWrites(engine, scenario.existingRows, scenario.posture !== 'single')) + .toEqual(refusalGoldenFor(scenario)); }); } }); @@ -541,7 +546,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is refused as well + * provenance. Under a wall the tenant's position create is not judged here * ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { @@ -573,25 +578,18 @@ const REFUSAL_GOLDEN: Record = { }, }; -/** - * Whether the plugin still declares the `sys_position` row object. C3 stage 8 - * retires it (ADR-0131 D3) and unregisters the walled data-door refusal with - * it; this harness registers the legacy table itself either way. - */ -const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); - /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is - * refused (`walled-catalog-data-door.ts`) while the plugin declares the row - * object: the catalog is the platform operator's, and a tenant may only assign - * it. The assignment cases do not move, and the built-in row refusals keep - * their `403 PERMISSION_DENIED`. + * database's rows. [ADR-0131 D3] Under a wall the tenant's position create is + * left out: the data-door create refusal pinned here retires with the + * `sys_position` object (C3 stage 8), and the walled create refusal is pinned at + * the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). The assignment + * cases do not move, and the built-in row refusals keep their + * `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const golden: Record = scenario.posture === 'single' || !PLUGIN_DECLARES_POSITION_ROWS - ? REFUSAL_GOLDEN - : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; + const { 'control: create a position row': _create, ...walledGolden } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' ? REFUSAL_GOLDEN : walledGolden; if (scenario.existingRows) return golden; const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; From 08524a15633ffea4aaee1e0242ba491a77ef1be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:54:49 +0000 Subject: [PATCH 6/8] test(plugin-auth): the grant-reader suites register the retired sys_permission_set table before seeding its bucket rows Seat ruling: the two suites model an existing database whose platform-bucket rows outlive the object declaration, so each registers the legacy sys_permission_set table shape (a test-only copy under src/__tests__, the shape plugin-security's testkit holds) whenever the plugin no longer declares it, and seeds the rows unconditionally. Replaces the previous commit's skip of the seed after stage 8; the walled organization copies and the legacy-id write read their rows on both trees again, as on main. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../legacy-catalog-objects.testkit.ts | 69 +++++++++++++++++++ .../src/grant-readers-by-name.golden.test.ts | 14 ++-- .../src/grant-readers-unnamed-grant.test.ts | 22 +++--- 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts new file mode 100644 index 00000000000..a24cdcdb93a --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the TABLE shape of the retired `sys_permission_set` row + * object (ADR-0131 D3; retired from plugin-security by C3 stage 8), for a suite + * that models an EXISTING database whose platform-bucket rows + * (`legacy-catalog-rows.testkit.ts`) outlive the object declaration until the + * organization-ownership migration (ADR-0131 D10) takes them. + * + * The columns are the ones those rows carry, as the last release that declared + * the object stored them. A test-only copy of plugin-security's + * `src/__tests__/legacy-catalog-objects.testkit.ts` shape rather than an import + * of it: that file is no export of the package, and a relative import out of + * this package would make a test read another package's source. + */ + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const SysPermissionSet = ObjectSchema.create({ + name: 'sys_permission_set', + label: 'Permission Set', + pluralLabel: 'Permission Sets', + isSystem: true, + managedBy: 'config', + displayNameField: 'label', + nameField: 'label', + fields: { + label: Field.text({ label: 'Display Name', required: true, maxLength: 255 }), + name: Field.text({ label: 'API Name', required: true, maxLength: 100 }), + description: Field.textarea({ label: 'Description', required: false }), + object_permissions: Field.textarea({ label: 'Object Permissions', required: false }), + field_permissions: Field.textarea({ label: 'Field Permissions', required: false }), + system_permissions: Field.textarea({ label: 'System Permissions', required: false }), + row_level_security: Field.textarea({ label: 'Row-Level Security', required: false }), + tab_permissions: Field.textarea({ label: 'Tab Permissions', required: false }), + admin_scope: Field.textarea({ label: 'Admin Scope', required: false }), + active: Field.boolean({ label: 'Active', defaultValue: true }), + package_id: Field.text({ label: 'Owning Package', required: false, maxLength: 255 }), + managed_by: Field.select({ + label: 'Managed By', + required: false, + defaultValue: 'admin', + options: [ + { value: 'platform', label: 'Platform' }, + { value: 'package', label: 'Package' }, + { value: 'admin', label: 'Admin' }, + ], + }), + customized: Field.boolean({ label: 'Customized', defaultValue: false }), + id: Field.text({ label: 'Permission Set ID', required: true, readonly: true }), + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true }), + updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true }), + }, + indexes: [ + { fields: ['name'], unique: 'organization' }, + { fields: ['active'] }, + { fields: ['package_id'] }, + ], +}); + +/** + * `objects` plus the retired `sys_permission_set` table when it does not + * already declare one — for a suite that boots the security plugin's objects + * AND models an existing database's bucket rows, whether or not the plugin + * still declares the table. + */ +export function withLegacyPermissionSetTable(objects: readonly T[]): Array { + return objects.some((o) => o?.name === SysPermissionSet.name) ? [...objects] : [...objects, SysPermissionSet]; +} diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index ffbd50b6fbe..ec3d3c36520 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -41,6 +41,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; +import { withLegacyPermissionSetTable } from './__tests__/legacy-catalog-objects.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -128,7 +129,9 @@ async function standingByReader(posture: Posture): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [ + // The retired `sys_permission_set` table too: an existing database still + // holds its platform-bucket rows (C3 stage 8 retires the object). + objects: withLegacyPermissionSetTable([ SysUser, SysAccount, SysMember, SysOrganization, ...(manifest.objects ?? []).filter((o: any) => ['sys_position', 'sys_user_position', 'sys_permission_set', 'sys_position_permission_set', 'sys_user_permission_set'] .includes(o?.name)), - ], + ]), } as any); await engine.syncSchemas(); await plugin.start(ctx); @@ -123,11 +126,8 @@ async function soleGrantAnchoredAdmin(): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } - // An existing database: the platform-bucket rows an earlier release seeded, - // where the row object is still registered (C3 stage 8 retires it). - if (engine.getObject?.('sys_permission_set')) { - await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); - } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine }; @@ -178,12 +178,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the last-administrator gu it('writing the legacy id moves no standing: nothing derives a name from it, so the write is permitted', async () => { const { engine } = await soleGrantAnchoredAdmin(); const grant = await adminGrant(engine); - // The id of another set's legacy catalog row — read off the row where that - // object is still registered, a literal one once it is retired (C3 stage 8). - const viewerId = engine.getObject?.('sys_permission_set') - ? ((await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[])[0]?.id - : 'ps_legacy_viewer_readonly'; - await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewerId, reason: 'renewed' }, SYS); + const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[]; + await engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id, reason: 'renewed' }, SYS); expect(await adminGrant(engine)).toMatchObject({ permission_set: 'admin_full_access', reason: 'renewed' }); }); }); From e40fe491c1ae73ffe48e606877cd794a8d6b11fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:11:03 +0000 Subject: [PATCH 7/8] test(plugin-auth): the legacy-table helper takes the manifest's untyped object list The plugin's manifest hands its objects as unknown[], so the helper reads each entry's name defensively; the test layer compiles again under tsconfig.test.json (check:test-typecheck). Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/__tests__/legacy-catalog-objects.testkit.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts index a24cdcdb93a..064b9742115 100644 --- a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-objects.testkit.ts @@ -64,6 +64,7 @@ export const SysPermissionSet = ObjectSchema.create({ * AND models an existing database's bucket rows, whether or not the plugin * still declares the table. */ -export function withLegacyPermissionSetTable(objects: readonly T[]): Array { - return objects.some((o) => o?.name === SysPermissionSet.name) ? [...objects] : [...objects, SysPermissionSet]; +export function withLegacyPermissionSetTable(objects: readonly unknown[]): unknown[] { + const declared = objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === SysPermissionSet.name); + return declared ? [...objects] : [...objects, SysPermissionSet]; } From 9d9bdd96b2457b51ff32a86c3928c73c5771ad98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:18:42 +0000 Subject: [PATCH 8/8] test(plugin-security): restore the walled data-door suite; builtin-positions judges the walled create while sys_position is declared Patch round 1 (contract review on e40fe491c1, seat ruling): - walled-catalog-data-door.test.ts is restored with main's bytes: five of its six cases have no other pin until stage 8 removes the door, and the deletion clause asks for one. Stage 8 deletes it together with the door. - builtin-positions.boot keeps the walled `control: create a position row` key (403 PERMISSION_DENIED) wherever the plugin declares sys_position, and leaves it out only once the object is retired. Its comments name the door that refuses the create: walled-catalog-data-door.ts, pinned by that suite and by wall-catalog-refusal's data-door case. Every other key unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-Authored-By: Claude --- .../src/builtin-positions.boot.test.ts | 45 ++-- .../src/walled-catalog-data-door.test.ts | 227 ++++++++++++++++++ 2 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a201c11965e..454884d61ad 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -403,7 +403,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated * that show the refusals are not blanket: a write through the data door by the * organization administrator (superuser wildcard, not a system context). */ -async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, walled: boolean): Promise> { +async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, judgeCreate: boolean): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; @@ -420,9 +420,10 @@ async function tenantPositionWrites(engine: ObjectQL, existingRows: boolean, wal )); return { 'assign a position the security catalog does not hold': dangling, - // Under a wall the tenant's create is not judged here: the walled create - // refusal is pinned at the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). - ...(walled ? {} : { 'control: create a position row': created }), + // Judged wherever the plugin declares the `sys_position` row object (under a + // wall, the walled data-door refusal's: `walled-catalog-data-door.ts`); left + // out once C3 stage 8 retires the object and that door together. + ...(judgeCreate ? { 'control: create a position row': created } : {}), // [ADR-0131 D3/D4] This harness mounts no metadata door, so the row gets // no definition: a position only a row carries grants nothing, and an // assignment naming it is refused like a dangling name. @@ -533,7 +534,7 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { // A fresh database holds no built-in row to delete or relabel; the two // refusals are read where the rows are, on an existing database. const { engine } = booted.get(name)!; - expect(await tenantPositionWrites(engine, scenario.existingRows, scenario.posture !== 'single')) + expect(await tenantPositionWrites(engine, scenario.existingRows, judgesCreate(scenario))) .toEqual(refusalGoldenFor(scenario)); }); } @@ -546,8 +547,8 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. Under a wall the tenant's position create is not judged here - * ({@link refusalGoldenFor}). + * provenance. Under a wall the tenant's position create is refused as well, + * while the plugin declares the row object ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -578,18 +579,32 @@ const REFUSAL_GOLDEN: Record = { }, }; +/** Whether the plugin still declares the `sys_position` row object; C3 stage 8 retires it (ADR-0131 D3). */ +const PLUGIN_DECLARES_POSITION_ROWS = securityObjects.some((o) => o.name === 'sys_position'); + +/** Whether a scenario judges the tenant's position create ({@link refusalGoldenFor}). */ +function judgesCreate(scenario: Scenario): boolean { + return scenario.posture === 'single' || PLUGIN_DECLARES_POSITION_ROWS; +} + /** * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing - * database's rows. [ADR-0131 D3] Under a wall the tenant's position create is - * left out: the data-door create refusal pinned here retires with the - * `sys_position` object (C3 stage 8), and the walled create refusal is pinned at - * the `/meta` wall (`wall-catalog-refusal.dogfood.test.ts`). The assignment - * cases do not move, and the built-in row refusals keep their - * `403 PERMISSION_DENIED`. + * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is + * refused by the walled data-door refusal (`walled-catalog-data-door.ts`, also + * pinned by `walled-catalog-data-door.test.ts` and by the data-door case of + * `wall-catalog-refusal.dogfood.test.ts`): the catalog is the platform + * operator's, and a tenant may only assign it. That holds while the plugin + * declares the `sys_position` row object; C3 stage 8 retires the object and that + * door together, and the create is then left out. The assignment cases do not + * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. */ function refusalGoldenFor(scenario: Scenario): Record { - const { 'control: create a position row': _create, ...walledGolden } = REFUSAL_GOLDEN; - const golden: Record = scenario.posture === 'single' ? REFUSAL_GOLDEN : walledGolden; + const { 'control: create a position row': _create, ...withoutCreate } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' + ? REFUSAL_GOLDEN + : PLUGIN_DECLARES_POSITION_ROWS + ? { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } } + : withoutCreate; if (scenario.existingRows) return golden; const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts new file mode 100644 index 00000000000..003a885f3f6 --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the + * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). + * + * Measured before it on this same harness shape (a real `ObjectQL` over a real + * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the + * organization-scoping service): an organization-bound administrator holding + * the superuser wildcard created a position at the data door, and it landed + * as a row of that organization. Nothing judged a catalog create by posture. + * + * Pinned here, per posture: + * - `isolated` and `group`: the tenant's create, update and delete of a + * position, a permission set and a capability are refused with the ADR-0112 + * envelope `403 PERMISSION_DENIED`, and nothing is stored; + * - the CONTROL: the same tenant still assigns a catalog position to a member + * of its organization; + * - the platform operator rung and a system write are admitted; + * - `single`: the same tenant's create is accepted, unchanged. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysCapability } from './objects/sys-capability.object.js'; +import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; + +const ORG = 'org_a'; + +/** The fallback every authenticated caller resolves: it grants nothing here. */ +const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; + +/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +type Posture = 'single' | 'isolated' | 'group'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.walled-catalog-data-door', + name: 'Walled catalog data door', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + SysCapability, SysMember, SysMetadataActivation, + ], + } as any); + await engine.syncSchemas(); + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, QA_ADMIN], + }, + ...(posture === 'single' + ? {} + : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const walled = posture !== 'single'; + const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; + // The catalog the tenant may assign: one position, declared in the registry. + engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, + 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); + // Rows the tenant will try to change: one per catalog object. + await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); + await engine.insert('sys_permission_set', { + id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', + object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }, { context: system } as any); + await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); + // The member an assignment names: a member of the organization it is scoped to. + await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, + { context: { isSystem: true, tenantId: ORG } } as any); + + const tenant = walled + ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } + : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; + return { engine, tenant, system }; +} + +async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { + try { + await run; + return { ok: true }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { ok: false, code: r.code, status: r.status }; + } +} + +const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; + +async function storedNames(engine: ObjectQL, object: string, name: string): Promise { + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); + return Array.isArray(rows) ? rows.length : 0; +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { + it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); + expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); + expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); + }); + + it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) + .toEqual(REFUSED); + const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; + expect(row?.label).toBe('Existing'); + expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); + }); + + it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) + .toEqual({ ok: true }); + }); + + it('the platform operator rung and a system write are admitted', async () => { + const { engine, tenant, system } = await boot(posture); + const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) + .toEqual({ ok: true }); + }); + + it('a tenant rung below the operator is refused like a context that carries none', async () => { + const { engine, tenant } = await boot(posture); + const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) + .toEqual(REFUSED); + }); + }); +} + +describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { + it('the same administrator creates a position and a permission set', async () => { + const { engine, tenant } = await boot('single'); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); + }); +}); + +describe('the judged objects', () => { + it('are exactly the three security catalog objects — assignment objects are never judged', () => { + expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', + }); + }); +});