From d9ef523bc1cb55119799f796d6443168244879a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:23:21 +0000 Subject: [PATCH 1/4] test(dogfood,plugin-security): fixtures read the security catalog, not a seeded sys_permission_set row (#15204 stage 6b-2, T2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- .../__tests__/legacy-catalog-rows.testkit.ts | 58 ++++++ ...latform-admin-authenticable-target.test.ts | 3 + ...platform-admin-promotion-selection.test.ts | 3 + ...grant-permission-set-name-backfill.test.ts | 3 + ...nt-permission-set-name.equivalence.test.ts | 3 + .../src/grant-readers-by-name.golden.test.ts | 3 + .../src/grant-readers-unnamed-grant.test.ts | 11 ++ ...lve-authz-grant-set-by-name.golden.test.ts | 3 + .../resolve-authz-grant-set-by-name.test.ts | 3 + .../activity-field-values.dogfood.test.ts | 4 +- ...tivity-label-display-field.dogfood.test.ts | 4 +- .../activity-parent-read-gate.dogfood.test.ts | 5 +- .../activity-text-predicate.dogfood.test.ts | 4 +- .../activity-withheld-update.dogfood.test.ts | 5 +- ...n-ledger-decision-metadata.dogfood.test.ts | 4 +- ...-engine-middleware-objects.dogfood.test.ts | 5 +- ...oval-snapshot-masked-field.dogfood.test.ts | 5 +- .../qa/dogfood/test/armed.dogfood.test.ts | 22 +-- ...achments-permission-matrix.dogfood.test.ts | 7 +- ...ments-unscoped-delete-gate.dogfood.test.ts | 15 +- ...audit-log-audit-capability.dogfood.test.ts | 9 +- .../audit-log-field-values.dogfood.test.ts | 4 +- ...audit-log-parent-read-gate.dogfood.test.ts | 5 +- .../authored-row-write-scope.dogfood.test.ts | 5 +- .../test/bulk-widener-probe.dogfood.test.ts | 7 +- .../qa/dogfood/test/catalog-permission-set.ts | 63 +++++++ .../cbp-explain-master-write.dogfood.test.ts | 9 +- ...t-attachment-comment-gates.dogfood.test.ts | 5 +- ...comments-permission-matrix.dogfood.test.ts | 5 +- .../explain-write-door-parity.dogfood.test.ts | 5 +- ...tity-admin-fields-org-peer.dogfood.test.ts | 5 +- ...lic-read-write-write-floor.dogfood.test.ts | 5 +- ...ner-anchor-and-bulk-writes.dogfood.test.ts | 5 +- ...-write-refusal-not-visible.dogfood.test.ts | 5 +- ...sion-set-assignment-window.dogfood.test.ts | 5 +- ...clone-boot-unowned-warning.dogfood.test.ts | 166 ------------------ ...on-set-lock-row-provenance.dogfood.test.ts | 7 +- ...te-through-package-binding.dogfood.test.ts | 4 + ...ite-unreadable-not-matched.dogfood.test.ts | 5 +- ...arch-companion-field-scope.dogfood.test.ts | 4 +- ...aring-rule-org-less-caller.dogfood.test.ts | 5 +- ...howcase-agent-intersection.dogfood.test.ts | 5 +- ...se-client-liaison-fixtures.dogfood.test.ts | 5 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 5 +- .../showcase-expand-crud-gate.dogfood.test.ts | 5 +- ...owcase-fls-read-mask-strip.dogfood.test.ts | 5 +- ...ase-invoice-seed-isolation.dogfood.test.ts | 5 +- ...case-permission-projection.dogfood.test.ts | 6 + .../showcase-permission-zoo.dogfood.test.ts | 33 ++-- .../test/two-doors-permission.dogfood.test.ts | 42 +---- ...or-unreadable-is-not-found.dogfood.test.ts | 5 +- 51 files changed, 311 insertions(+), 308 deletions(-) create mode 100644 packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts create mode 100644 packages/qa/dogfood/test/catalog-permission-set.ts delete mode 100644 packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts diff --git a/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts new file mode 100644 index 00000000000..f73aed75f95 --- /dev/null +++ b/packages/plugins/plugin-security/src/__tests__/legacy-catalog-rows.testkit.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the organization-less `sys_permission_set` rows an EARLIER + * release's platform-admin bootstrap seeded for the platform's default sets + * (the "platform bucket"). + * + * [C3 stage 6b-2] No boot writes these rows any more (ADR-0131 D3: the sets are + * served from the security catalog, and a grant names its set). A suite that + * models an EXISTING database — a grant written before the name column, whose + * `permission_set_id` points at one of these rows; an organization's copy cloned + * from the bucket — writes them itself, here, in the shape the retired seeder + * wrote: `managed_by: 'platform'`, active, the set's facets as JSON columns. + * Idempotent: a name that already has an organization-less row is left alone. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; + +/** The legacy row id for a default set — stable, so a fixture can point a grant at it. */ +export function legacyBucketRowId(name: string): string { + return `ps_bucket_${name}`; +} + +/** Write the legacy platform-bucket row for each set; returns `name → id`. */ +export async function seedLegacyPlatformBucket( + engine: { find: (...args: any[]) => Promise; insert: (...args: any[]) => Promise }, + sets: readonly PermissionSet[], +): Promise> { + const ids: Record = {}; + for (const ps of sets) { + if (!ps.name) continue; + const existing = ((await engine.find('sys_permission_set', { where: { name: ps.name }, context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own?.id) { + ids[ps.name] = String(own.id); + continue; + } + const id = legacyBucketRowId(ps.name); + await engine.insert('sys_permission_set', { + id, + name: ps.name, + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + active: true, + managed_by: 'platform', + }, { context: SYS }); + ids[ps.name] = id; + } + return ids; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 42c76eb2cad..bfcac098284 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -50,6 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -319,6 +320,8 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC // written by hand — which is exactly the row the old selector would have // picked. const { logger: seedLogger } = collectingLogger(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const seedPass = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { logger: seedLogger, }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 0d0fec1689c..095c5a6c164 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -70,6 +70,7 @@ import { PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, PLATFORM_ADMIN_CANDIDATE_SCAN_CEILING, } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './objects/sys-permission-set.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -744,6 +745,8 @@ describe('#16682 — the promotion target is chosen, not sampled', () => { // this change's to make. A declared owner must not move an existing grant. const engine = await boot(); await seedUser(engine as any, 'usr_person0', 'person0@demo.example', '2025-01-01T00:00:00.000Z', false); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine as any, defaultPermissionSets); const first = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, {}); expect(first.reason).toBe('no_authenticable_user'); const sets = await findRows(engine, 'sys_permission_set', { name: 'admin_full_access' }); diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts index 337f63645a1..9ba81844b02 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name-backfill.test.ts @@ -34,6 +34,7 @@ import { SysMigration } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -175,6 +176,8 @@ async function boot(posture: Posture, opts: { ledger?: boolean } = {}): Promise< for (const ps of [...defaultPermissionSets, QA_ADMIN, OWN_SET, OTHER_SET]) { engine.registry.registerItem('permission', structuredClone(ps) as any, 'name' as any, CATALOG_PACKAGE); } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const seeded = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(seeded.adminPromoted).toBe(false); // no user yet — the golden world promotes after its users exist diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index 659cc5371da..c170cf74428 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -44,6 +44,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -158,6 +159,8 @@ async function grantsByPrincipal(posture: Posture): Promise => describe('[ADR-0131 D4] a grant that names nothing — explain', () => { it('reports no dropped grant for it: an unnamed grant names no set to report', async () => { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_member', '2025-03-01T00:00:00.000Z'); await engine.insert('sys_permission_set', { id: 'ps_off', name: 'legacy_reports', label: 'Legacy' }, { context: SYS } as any); @@ -178,6 +181,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst */ async function standingAdminUnnamed(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); expect((await bootstrapPlatformAdmin(engine, defaultPermissionSets)).reason).toBe('no_users'); await user(engine, 'usr_oldest', '2024-01-01T00:00:00.000Z'); await user(engine, 'usr_admin', '2025-01-01T00:00:00.000Z'); @@ -192,6 +197,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('control — named, the grant is the holder: no promotion, and it names the claim target', async () => { const engine = await standingAdminUnnamed(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_admin' }); expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_admin'); @@ -200,6 +207,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst it('unnamed, the promotion is WITHHELD — no second unscoped grant is minted for the oldest account', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(report.adminPromoted).toBe(false); expect(report.reason).toBe('admin_grant_unnamed'); @@ -217,6 +226,8 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst describe('[ADR-0131 D4] a grant that names nothing — the organization-administrator reconcile', () => { async function orgAdmin(): Promise { const engine = await boot(); + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, defaultPermissionSets); await bootstrapPlatformAdmin(engine, defaultPermissionSets); await user(engine, 'usr_orgadmin', '2025-02-01T00:00:00.000Z'); await engine.insert( diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index e731cf7d8ed..e02cbb25708 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -38,6 +38,7 @@ import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/pl import { SecurityPlugin } from './security-plugin.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPosition } from './objects/sys-position.object.js'; import { SysUserPosition } from './objects/sys-user-position.object.js'; @@ -156,6 +157,8 @@ async function resolverByPrincipal(posture: Posture): Promise; @@ -124,8 +125,8 @@ describe('[#21080] comment threads and activity rows: the analytics door answers adminTok = await stack.signIn(); memberTok = await stack.signUp('mw-objects-member@verify.test'); const memberId = (await ql.findOne('sys_user', { where: { email: 'mw-objects-member@verify.test' }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: activityReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, activityReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts index 9a8888c705e..7421d33b481 100644 --- a/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts +++ b/packages/qa/dogfood/test/approval-snapshot-masked-field.dogfood.test.ts @@ -42,6 +42,7 @@ import { defineStack, defineFlow } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJECT = 'snapmask_item'; const POSITION = 'snapmask_reviewer'; @@ -197,8 +198,8 @@ describe('[#20964] approval snapshot field served masked on the data plane', () await ql.insert('sys_position', { id: 'pos_snapmask', name: POSITION, label: 'Reviewer', active: true }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_m', user_id: memberId, position: POSITION, organization_id }, SYS); await ql.insert('sys_user_position', { id: 'hold_snapmask_u', user_id: unmaskerId, position: POSITION, organization_id }, SYS); - const unmask = await ql.findOne('sys_permission_set', { where: { name: unmaskSet.name }, context: { isSystem: true } }); - expect(unmask?.id, 'fixture permission set seeded').toBeTruthy(); + const unmask = await catalogPermissionSet(ql, unmaskSet.name); + expect(unmask, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [unmaskerId, submitterId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: unmask.name }, SYS); } diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index e708393c9fc..36aade5c521 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -238,10 +238,12 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('[control] finds a set that really seeded — so its negative reading is a real negative', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a permission set that failed to seed grants the principal nothing', + // [C3 stage 6b-2] A row that really landed: the member this suite + // signed up. No boot writes a `sys_permission_set` row any more. + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }).read(); expect(verdict.armed).toBe(true); expect(verdict.observed).toContain('is seeded'); @@ -262,8 +264,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED when the row exists but fails its own predicate', async () => { const verdict = await seededArmed({ stack: outside, - object: 'sys_permission_set', - where: { name: 'member_default' }, + object: 'sys_user', + where: { email: 'armed-outside@verify.test' }, armed: () => false, control: 'a seeded row that must also carry something', disarmedBy: 'the row seeded but not the part the control rides on', @@ -383,10 +385,10 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( }), seededArmed({ stack: inside, - object: 'sys_permission_set', - where: { name: 'member_default' }, - control: 'the platform baseline permission set', - disarmedBy: 'a set that failed to seed grants the principal nothing', + object: 'sys_user', + where: { email: 'armed-inside@verify.test' }, + control: 'the signed-up member', + disarmedBy: 'a sign-up that never landed holds nothing', }), ]), ).resolves.toBeUndefined(); diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index f1e1fe3d5bc..1d072ec2a10 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -46,6 +46,7 @@ import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/ import { organizationsAvailable, warnIfUnavailable } from './enterprise-organizations.js'; import { assertArmed, principalArmed } from './armed.js'; import { SysAttachment } from '@objectstack/platform-objects/audit'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DAY_MS = 86_400_000; @@ -163,8 +164,8 @@ describe('attachments permission matrix (#2755)', () => { // Domain grant (see attachmentManagerSet): memberA/memberB manage // attachments; the baseline member deliberately keeps only the // `member_default` everyone-anchor baseline (no delete bit anywhere). - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } @@ -719,7 +720,7 @@ describe('attachments without service-storage (a′)', () => { const editorTok = await stack.signUp('att-nostorage-b@verify.test'); const uploaderId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-a@verify.test' }, context: SYS }))?.id; const editorId = (await ql.findOne('sys_user', { where: { email: 'att-nostorage-b@verify.test' }, context: SYS }))?.id; - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'att_attachment_manager' }, context: SYS }); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); for (const userId of [uploaderId, editorId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts index fd05f397eea..cf672abbae9 100644 --- a/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts @@ -58,6 +58,7 @@ import { FILE_BYTES, type AttachmentsHarness, } from './fixtures/attachments-authz-harness.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -116,11 +117,8 @@ describe('sys_attachment delete gate under an unscoped multi-delete (#9483)', () // The delete BIT first. Without it RBAC refuses ahead of the attachment // hook with PERMISSION_DENIED, and every assertion below would be green for // the wrong reason — a denial from a layer this file is not about. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, @@ -311,11 +309,8 @@ describe('sys_attachment unscoped multi-delete is refused on its SHAPE, not on e // Same RBAC premise as the block above: without the delete bit the refusals // below would come from RBAC, about a different question entirely. - const managerSet = await ql.findOne('sys_permission_set', { - where: { name: 'att_attachment_manager' }, - context: SYS, - }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'att_attachment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: memberId, permission_set: managerSet.name }, diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 4cbacd32505..ece97fc5786 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -45,6 +45,7 @@ import { defineStack, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** The capability under test, as `PLATFORM_CAPABILITIES` declares it. */ const CAP = 'view_all_audit_log'; @@ -178,8 +179,8 @@ describe('[#21260] the ledger audit capability exempts its holder from the paren const userIdOf = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, context: { ...SYS } }))?.id ?? ''); const grant = async (email: string, setName: string) => { - const set = await ql.findOne('sys_permission_set', { where: { name: setName }, context: { ...SYS } }); - expect(set?.id, `fixture permission set ${setName} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, setName); + expect(set, `fixture permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await userIdOf(email), permission_set: set.name }, { context: { ...SYS } }); }; for (const who of ['holder', 'reader', 'writer'] as const) { @@ -406,8 +407,8 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga expect((await stack.apiAs(tok[who], 'POST', '/auth/organization/set-active', { organizationSlug: `alc-org-${who}` })).status).toBe(200); } const userA = await ql.findOne('sys_user', { where: { email: 'alc-org-a@verify.test' }, context: { ...SYS } }); - const set = await ql.findOne('sys_permission_set', { where: { name: capOnly.name }, context: { ...SYS } }); - await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: set.name }, { context: { ...SYS } }); + expect(await catalogPermissionSet(ql, capOnly.name), 'fixture permission set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: capOnly.name }, { context: { ...SYS } }); for (const who of ['a', 'b'] as const) { const res = await stack.apiAs(tok[who], 'POST', `/data/${OBJ}`, { name: `gone ${who}` }); expect(res.status).toBeLessThan(300); diff --git a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts index 159cf83fcd6..c01ed260ce8 100644 --- a/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts @@ -62,6 +62,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = 'alv_item'; const LEDGER = 'sys_audit_log'; @@ -188,8 +189,7 @@ describe('[#21155] the compliance ledger serves a parent field value only to a r const userId = await idOf('sys_user', { email }); const sets = who === 'writer' ? READERS.control : READERS[who as keyof typeof READERS]; for (const name of sets) { - const setId = await idOf('sys_permission_set', { name }); - expect(setId, `fixture permission set ${name} seeded`).toBeTruthy(); + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: name }, SYS); } } diff --git a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts index c9ac967acd0..6baff4bef46 100644 --- a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts @@ -38,6 +38,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { commentsFixtureStack, cmtFixtureBaselineSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LEDGER = 'sys_audit_log'; @@ -81,8 +82,8 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i ql = await stack.kernel.getServiceAsync('objectql'); const readerId = (await ql.findOne('sys_user', { where: { email: READER_EMAIL }, context: SYS }))?.id; - const set = await ql.findOne('sys_permission_set', { where: { name: ledgerReaderSet.name }, context: SYS }); - expect(set?.id, 'fixture permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, ledgerReaderSet.name); + expect(set, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: readerId, permission_set: set.name }, { context: { ...SYS } }); const idOf = async (res: Response) => { diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index ea5c7c0de20..05582b0aa81 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -76,6 +76,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the two objects under probe ──────────────────────────────────────────── @@ -229,8 +230,8 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller expect(aliceId).toBeTruthy(); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, { context: { ...SYS } }); }; diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 3ea631d7122..2f6053e5831 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -58,6 +58,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; // ── the app under probe ──────────────────────────────────────────────────── @@ -188,10 +189,8 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => expect(bobId).toBeTruthy(); expect(aliceId).toBeTruthy(); - const setRow = await ql.findOne('sys_permission_set', { - where: { name: 'probe_widener' }, context: { ...SYS }, - }); - expect(setRow?.id, 'the app-declared widener set is seeded').toBeTruthy(); + const setRow = await catalogPermissionSet(ql, 'probe_widener'); + expect(setRow, 'the app-declared widener set is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: bobId, permission_set: setRow.name }, { context: { ...SYS } }); diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts new file mode 100644 index 00000000000..b8a036cc26b --- /dev/null +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, C3 stage 6b-2] Does the security catalog serve this permission + * set — the fixture precondition that replaced "is its `sys_permission_set` + * row seeded". + * + * No boot writes a `sys_permission_set` row any more: a set is served by name + * from the registry, and a grant names its set. So a fixture that grants a set + * asks the catalog the security plugin bound to its engine — the read the + * authorization resolver itself uses — and gets the definition back, or + * `undefined` when no reader holds the name. + */ + +import { securityCatalogReaderOf } from '@objectstack/core'; + +export async function catalogPermissionSet( + ql: unknown, + name: string, +): Promise> | undefined> { + const reader = securityCatalogReaderOf(ql); + if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); + return (await reader.resolve('permission', name))?.definition; +} + +/** + * The `sys_permission_set` row an EARLIER release's boot wrote for a declared + * set, written here by a fixture that models an EXISTING database (the data + * door's edit, reset and lock paths still run against such rows until the + * object retires). The body is the catalog definition's, in the column shape + * the retired seeders wrote; `managedBy` / `packageId` are the provenance they + * stamped (`'package'` with the owning package, `'platform'` for a platform + * default). Idempotent: an existing organization-less row is returned as is. + */ +export async function ensureLegacyPermissionSetRow( + ql: any, + name: string, + provenance: { managedBy: 'package' | 'platform' }, +): Promise { + const SYS = { isSystem: true }; + const existing = ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own) return own; + const def: any = await catalogPermissionSet(ql, name); + if (!def) throw new Error(`the security catalog serves no permission set '${name}'`); + await ql.insert('sys_permission_set', { + id: `ps_legacy_${name}`, + name, + label: def.label ?? name, + description: def.description ?? null, + object_permissions: JSON.stringify(def.objects ?? {}), + field_permissions: JSON.stringify(def.fields ?? {}), + system_permissions: JSON.stringify(def.systemPermissions ?? []), + row_level_security: JSON.stringify(def.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(def.tabPermissions ?? {}), + admin_scope: def.adminScope ? JSON.stringify(def.adminScope) : null, + active: true, + managed_by: provenance.managedBy, + ...(provenance.managedBy === 'package' && typeof def._packageId === 'string' ? { package_id: def._packageId } : {}), + }, { context: SYS }); + return ((await ql.find('sys_permission_set', { where: { name } }, { context: SYS })) ?? []) + .find((r: any) => !r.organization_id); +} diff --git a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts index aa8beceffe8..7c4adcbda1e 100644 --- a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts @@ -68,6 +68,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -257,8 +258,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do memberId = await uid('cpe-member@verify.test'); stewardTok = await stack.signUp('cpe-steward@verify.test'); stewardId = await uid('cpe-steward@verify.test'); - const stewardSet = await ql.findOne('sys_permission_set', { where: { name: contractStewardSet.name }, context: SYS }); - expect(stewardSet?.id, 'fixture permission set seeded').toBeTruthy(); + const stewardSet = await catalogPermissionSet(ql, contractStewardSet.name); + expect(stewardSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: stewardId, permission_set: stewardSet.name }, { context: { ...SYS } }); await assertArmed([ @@ -472,8 +473,8 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do beforeAll(async () => { boardStewardTok = await stack.signUp('cpe-board-steward@verify.test'); const boardStewardId = await uid('cpe-board-steward@verify.test'); - const set = await ql.findOne('sys_permission_set', { where: { name: boardStewardSet.name }, context: SYS }); - expect(set?.id, 'board steward permission set seeded').toBeTruthy(); + const set = await catalogPermissionSet(ql, boardStewardSet.name); + expect(set, 'board steward permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: boardStewardId, permission_set: set.name }, { context: { ...SYS } }); await assertArmed([ principalArmed({ diff --git a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts index 7c4d31b936c..eb928317827 100644 --- a/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts @@ -41,6 +41,7 @@ import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { cpgStack, cpgSecurity } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -161,8 +162,8 @@ describe('controlled_by_parent parent gates: sys_attachment and sys_comment judg adminId = await uid('admin@objectos.ai'); memberId = await uid('cpg-member@verify.test'); - const domainSet = await ql.findOne('sys_permission_set', { where: { name: 'cpg_files_and_threads' }, context: SYS }); - expect(domainSet?.id, 'fixture permission set seeded').toBeTruthy(); + const domainSet = await catalogPermissionSet(ql, 'cpg_files_and_threads'); + expect(domainSet, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: domainSet.name }, { context: { ...SYS } }); await assertArmed([ diff --git a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts index 090ad97fd1a..640ef281203 100644 --- a/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts @@ -70,6 +70,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { commentsFixtureStack, commentsFixtureSecurity } from './fixtures/comments-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('sys_comment permission matrix (#4630)', () => { // Domain grant (see commentManagerSet): both members may DELETE comments; // the everyone-anchor baseline carries no delete bit. - const managerSet = await ql.findOne('sys_permission_set', { where: { name: 'cmt_comment_manager' }, context: SYS }); - expect(managerSet?.id, 'fixture permission set seeded').toBeTruthy(); + const managerSet = await catalogPermissionSet(ql, 'cmt_comment_manager'); + expect(managerSet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const userId of [memberAId, memberBId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: managerSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts index 66707bf01af..7a82bca17da 100644 --- a/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts @@ -57,6 +57,7 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { ExplainOperationSchema, PermissionSetSchema, type ExplainOperation, type PermissionSet } from '@objectstack/spec/security'; import { cpgStack, cpgBaselineSet, cpgFilesAndThreadsSet } from './fixtures/cbp-parent-gates-fixture.js'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -205,8 +206,8 @@ describe('security/explain answers every write verb as its door does, on read-ab for (const { axis, set, email } of AXES) { const token = await stack.signUp(email); const userId = await uid(email); - const row = await ql.findOne('sys_permission_set', { where: { name: set.name }, context: SYS }); - expect(row?.id, `${set.name} seeded`).toBeTruthy(); + const row = await catalogPermissionSet(ql, set.name); + expect(row, `${set.name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: row.name }, { context: { ...SYS } }); tokens[axis] = token; } diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index 1232173b95b..d436351cdb0 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -47,6 +47,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { SysUser } from '@objectstack/platform-objects/identity'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const DOMAIN = 'org-peer-21237.verify.test'; @@ -149,8 +150,8 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a await memberOf(uid[who], role); } - const [activitySet] = await findRows(ql, 'sys_permission_set', { name: activityReadSet.name }, 1); - expect(activitySet, 'fixture permission set seeded').toBeTruthy(); + const activitySet = await catalogPermissionSet(ql, activityReadSet.name); + expect(activitySet, 'fixture permission set served by the security catalog').toBeTruthy(); for (const who of ['member', 'owner', 'admin']) { await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: activitySet.name }, { context: SYS }); } diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index 425c8db9a82..cf471402372 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -54,6 +54,7 @@ import { resolveAuthzContext } from '@objectstack/core'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** * [#8074] The control every case below measures, and the default that silences @@ -226,8 +227,8 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el const carolId = await uid('owdw-carol@verify.test'); const bindSet = async (userId: string, name: string) => { - const setRow = await ql.findOne('sys_permission_set', { where: { name }, context: { ...SYS } }); - expect(setRow?.id, `the app-declared set '${name}' is seeded`).toBeTruthy(); + const setRow = await catalogPermissionSet(ql, name); + expect(setRow, `the app-declared set '${name}' is served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: setRow.name }, diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 3b76301ed6c..d78bc2a21d7 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -33,6 +33,7 @@ import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const OBJ = '/data/showcase_private_note'; const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; @@ -97,8 +98,8 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => // Direct per-user grant of the delete set (never anchor-bound). const SYS = { isSystem: true } as const; - const delSet = await ql.findOne('sys_permission_set', { where: { name: 'anchor_note_delete' }, context: SYS }); - expect(delSet?.id, 'declared delete set seeded').toBeTruthy(); + const delSet = await catalogPermissionSet(ql, 'anchor_note_delete'); + expect(delSet, 'declared delete set served by the security catalog').toBeTruthy(); for (const userId of [aliceId, bobId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: delSet.name }, { context: { ...SYS } }); } diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index 43c4afcd889..979c594a2f7 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -58,6 +58,7 @@ import { AuditPlugin } from '@objectstack/plugin-audit'; import { buildAttSecret, buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet } from './fixtures/attachments-fixture.js'; import { buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -131,8 +132,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts index ff3e15c37fe..b017b6e7166 100644 --- a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const GRANTS = 'sys_user_permission_set'; @@ -80,8 +81,8 @@ describe('a permission-set assignment grants only inside its validity window (AD adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); - expect(set?.id, `the showcase seeded ${SET}`).toBeTruthy(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the showcase served by the security catalog ${SET}`).toBeTruthy(); for (const who of ['bare', 'open', 'before', 'inside', 'after', 'edited', 'falls', 'rises']) await sign(who); const now = Date.now(); diff --git a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts deleted file mode 100644 index 5a897849fe8..00000000000 --- a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21669] A permission set the environment CLONED is not reported, on the next -// boot, as a package declaration with no owner — over the real showcase -// composition, across a cold boot on one database file. -// -// ## What was broken -// -// The declared-permission boot loop (`bootstrapDeclaredPermissions`, -// plugin-security) walks every `permission` item in the engine registry. That -// registry holds more than package declarations: `loadMetaFromDb` hydrates every -// env-wide `sys_metadata` `permission` row into the same collection, and a set -// made with Setup's Clone action is one — with no package id, because it has -// none. The loop judged "unowned" before it looked at the row, so every boot -// (and every `metadata:reloaded`) logged -// `[permission_set_declaration_unowned] … the Setup admin surface … cannot see -// this set` for a set whose `managed_by:'admin'` row Setup lists and edits. -// Measured on `origin/main` before the fix through this file's own steps: one -// such line naming the clone, `skippedUnowned: 1`. -// -// ## Why a booted stack, booted twice -// -// The unit pins (`bootstrap-declared-permissions.test.ts`, `[#21669]`) hand the -// loop the item shape this boot produces. What they cannot show is that the -// real boot puts the clone into the walk at all — that is a property of the -// hydration, not of the loop, and only a restart on the same file shows it. So -// this file asserts the walk holds the clone with no package id BEFORE it -// asserts the warning is absent; without that precondition the absence would be -// vacuous. -// -// ## The clone is made the way the Setup dialog makes it -// -// The payload is built from the shipped `clone_permission_set` action's own -// declaration — its `target`, its `bodyExtra` and its `params` (the typed -// `label`/`name`, and every `defaultFromRow` facet copied from the base row) — -// and POSTed to the data door as the signed-in admin. Read, never restated: a -// facet added to the action reaches this pin without an edit here. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { type VerifyStack } from '@objectstack/verify'; -import { bootShowcase } from './showcase-boot.js'; -import { securityObjects } from '@objectstack/plugin-security'; -import { fileURLToPath } from 'node:url'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const SYS = { context: { isSystem: true } } as const; -const TOKEN = 'permission_set_declaration_unowned'; -/** A set the showcase package ships (`com.example.showcase`) — the clone's base. */ -const BASE = 'showcase_contributor'; -/** A name no package ships. */ -const CLONE = 'showcase_contributor_local_21669'; -const API_BASE = '/api/v1'; - -interface CloneParam { name?: string; field?: string; defaultFromRow?: boolean } -interface CloneAction { method: string; target: string; bodyExtra?: Record; params: CloneParam[] } - -/** The shipped Clone action, read off the object the security plugin registers. */ -function cloneAction(): CloneAction { - const object = (securityObjects as any[]).find((o) => o?.name === 'sys_permission_set'); - const action = (object?.actions ?? []).find((a: any) => a?.name === 'clone_permission_set'); - if (!action) throw new Error('clone_permission_set is missing from sys_permission_set.actions'); - return action as CloneAction; -} - -/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ -async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { - const lines: string[] = []; - const stdout = process.stdout.write.bind(process.stdout); - const stderr = process.stderr.write.bind(process.stderr); - const warn = console.warn; - (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; - (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; - console.warn = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); warn(...args); }; - try { - return { value: await run(), lines }; - } finally { - (process.stdout as any).write = stdout; - (process.stderr as any).write = stderr; - console.warn = warn; - } -} - -describe('[#21669] a cloned permission set boots without the unowned-declaration warning (showcase, cold boot)', () => { - let prevCwd: string; - let dir: string; - let dbFile: string; - let stack: VerifyStack | undefined; - /** Everything boot 2 wrote. */ - let bootLines: string[] = []; - /** Boot 2's engine. */ - let ql: any; - - beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); - dir = mkdtempSync(join(tmpdir(), 'dogfood-21669-')); - dbFile = join(dir, 'showcase.db'); - - // ── boot 1: clone a packaged set through the Setup dialog's own path ── - stack = await bootShowcase({ databaseFile: dbFile }); - const token = await stack.signIn(); - const engine: any = await stack.kernel.getServiceAsync('objectql'); - const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS); - expect(base?.managed_by, 'the base is a package-declared set').toBe('package'); - - const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); - const served: any = await read.json(); - const row = served?.record ?? served?.data?.record ?? served?.data; - const action = cloneAction(); - const body: Record = { ...(action.bodyExtra ?? {}) }; - for (const p of action.params) { - if (p.name === 'label') body.label = 'Contributor (local)'; - else if (p.name === 'name') body.name = CLONE; - else if (p.field && p.defaultFromRow) body[p.field] = row?.[p.field]; - } - expect(action.target.startsWith(API_BASE), action.target).toBe(true); - const created = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); - expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); - - // Harness health: the clone is the environment's own set — the row - // Setup lists, and the env-wide metadata row the next boot hydrates. - const [clone] = await engine.find('sys_permission_set', { where: { name: CLONE }, limit: 1 }, SYS); - expect({ managed_by: clone?.managed_by, package_id: clone?.package_id ?? null }) - .toEqual({ managed_by: 'admin', package_id: null }); - const stored = await engine.find('sys_metadata', { where: { type: 'permission', name: CLONE } }, SYS); - expect(stored.map((r: any) => ({ state: r.state, organization_id: r.organization_id ?? null }))) - .toEqual([{ state: 'active', organization_id: null }]); - await stack.stop(); - stack = undefined; - - // ── boot 2: same file, nothing authored ─────────────────────────────── - const second = await captureOutput(() => bootShowcase({ databaseFile: dbFile })); - stack = second.value; - bootLines = second.lines; - ql = await stack.kernel.getServiceAsync('objectql'); - }, 300_000); - - afterAll(async () => { - await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); - if (dir) rmSync(dir, { recursive: true, force: true }); - }); - - it('precondition: the boot loop walks the clone, with no package id', () => { - // ⛔ Without this the absence below proves nothing: a boot that stopped - // hydrating the clone would also log no warning for it. - const item = (ql.registry.listItems('permission') ?? []).find((i: any) => i?.name === CLONE); - expect(item, `${CLONE} is in the registry the loop walks`).toBeDefined(); - expect(item._packageId ?? item.packageId ?? null).toBeNull(); - }); - - it('control: the capture saw the loop report its pass', () => { - // The seeding pass logs one summary line per run, through the same - // logger the warning used — so an empty capture cannot pass the next case. - expect(bootLines.some((l) => l.includes('declared permission sets seeded into sys_permission_set'))).toBe(true); - }); - - it('the cloned set is not reported as an unowned declaration', () => { - const unowned = bootLines.filter((l) => l.includes(TOKEN) && l.includes(CLONE)); - expect(unowned).toEqual([]); - }); -}); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts index 72ccea5f54a..fd675b10c3c 100644 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts @@ -62,6 +62,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -163,7 +164,11 @@ describe('[#21789] the permission-set lock reads the row\'s provenance — the t expect(created.status, JSON.stringify(await created.clone().json().catch(() => ({})))).toBe(201); // Shape 3: a clone, made the way the Setup dialog makes it — the payload - // is built from the shipped action's own declaration. + // is built from the shipped action's own declaration. [C3 stage 6b-2] + // On an EXISTING database: no boot writes the shipped set's row any + // more, but an earlier one did (the server clone door, stage CD, is + // what reaches a fresh database). + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); const [base] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); const read = await stack.apiAs(token, 'GET', `/data/sys_permission_set/${base.id}`); const served: any = await read.json(); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts index f0dba0ef8cc..10b54cd131f 100644 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts @@ -45,6 +45,7 @@ import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; /** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); @@ -122,6 +123,9 @@ describe('[#21861] a data-door edit of a permission set updates its own sys_meta stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); + // [C3 stage 6b-2] The control runs on an EXISTING database: no boot + // writes the shipped set's row any more, but an earlier one did. + await ensureLegacyPermissionSetRow(ql, SHIPPED, { managedBy: 'package' }); // A writable runtime package, and a set saved into it at the metadata door. const pkg = await stack.apiAs(token, 'POST', '/packages', { diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 9a94491654c..f057cadfa14 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -57,6 +57,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -154,8 +155,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); diff --git a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts index f881b656746..032002a3609 100644 --- a/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/search-companion-field-scope.dogfood.test.ts @@ -37,6 +37,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PinyinSearchPlugin } from '@objectstack/plugin-pinyin-search'; +import { catalogPermissionSet } from './catalog-permission-set.js'; /** `翟璐` normalizes to `zhailu zl` — the term below lives in the companion only. */ const CJK_NAME = '翟璐'; @@ -171,9 +172,8 @@ describe('dogfood: with the pinyin search companion on, a member searches only w const idOf = async (object: string, where: Record) => String((await ql.findOne(object, { where, ...SYS }))?.id ?? ''); const userId = await idOf('sys_user', { email: memberEmail }); - const setId = await idOf('sys_permission_set', { name: memberSet.name }); expect(userId, 'member user seeded').toBeTruthy(); - expect(setId, 'member permission set seeded').toBeTruthy(); + expect(await catalogPermissionSet(ql, memberSet.name), 'member permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: memberSet.name }, SYS); memberRowId = await create(memberToken, ROWS, { name: CJK_NAME }); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index e184e421ee0..a3b0a6f97b8 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -64,6 +64,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { leaveOrganization } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -186,8 +187,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // A platform operator outside the organization: `admin_full_access` held // globally, the grant a platform administrator holds under `single`. await stack.signUp(OPERATOR_EMAIL, PASSWORD); - const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); - expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); + expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, organization_id: null, }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts index ce98ead44a0..3dd5a007252 100644 --- a/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -48,8 +49,8 @@ describe('showcase: ADR-0090 D10 agent intersection (served engine)', () => { // The agent holds the auditor set (viewAllRecords on the private note); the // delegator holds NOTHING beyond the additive member_default baseline. - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); - expect(auditor?.id, 'showcase_auditor seeded').toBeTruthy(); + const auditor = await catalogPermissionSet(ql, 'showcase_auditor'); + expect(auditor, 'showcase_auditor served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: agentId, permission_set: auditor.name }, { context: SYS }); // A private note owned by int-owner (NOT the delegator) — the VAMA probe. diff --git a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts index 94390f593c3..3c7d22063ea 100644 --- a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const LIAISON_SET = 'showcase_client_liaison'; @@ -78,8 +79,8 @@ describe('showcase client-liaison fixtures (#9308 fixtures 2 + 4)', () => { ql = await stack.kernel.getServiceAsync('objectql'); liaisonTok = await stack.signUp(LIAISON_EMAIL); - const set = await ql.findOne('sys_permission_set', { where: { name: LIAISON_SET }, context: SYS }); - expect(set?.id, `${LIAISON_SET} is seeded by the security bootstrap`).toBeTruthy(); + const set = await catalogPermissionSet(ql, LIAISON_SET); + expect(set, `${LIAISON_SET} is served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email: LIAISON_EMAIL }, context: SYS }))?.id; await ql.insert('sys_user_permission_set', { user_id: uid, permission_set: set.name }, { context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 01b6bcf89ac..821d7b294d2 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -67,6 +67,7 @@ import { fileURLToPath } from 'node:url'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -343,8 +344,8 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const email = `crudmx-${set.replace(/^showcase_/, '')}@verify.test`; const token = await stack.signUp(email); if (set !== BASELINE_SET) { - const ps = await ql.findOne('sys_permission_set', { where: { name: set }, context: SYS }); - expect(ps?.id, `permission set ${set} seeded by the security bootstrap`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, set); + expect(ps, `permission set ${set} served by the security catalog`).toBeTruthy(); const uid = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; expect(uid, `persona ${email} provisioned`).toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts index c0ca3dec226..b8f474c5640 100644 --- a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts @@ -36,6 +36,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -75,8 +76,8 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# const contribId = (await ql.findOne('sys_user', { where: { email: CONTRIB_EMAIL }, context: SYS }))?.id; expect(contribId, 'contributor user provisioned').toBeTruthy(); await ql.insert('sys_user_position', { user_id: contribId, position: 'contributor' }, { context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - expect(contribSet?.id, 'the app’s contributor set is seeded').toBeTruthy(); + const contribSet = await catalogPermissionSet(ql, 'showcase_contributor'); + expect(contribSet, 'the app’s contributor set is served by the security catalog').toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: contribId, permission_set: contribSet.name }, diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index c23377e797c..e0a67c590c4 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -74,6 +74,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -135,8 +136,8 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked active: true, }); expect(authored.status, 'scratch set authored through the data door').toBeLessThan(300); - const ps = await ql.findOne('sys_permission_set', { where: { name: SCRATCH_SET }, context: SYS }); - expect(ps?.id, 'scratch permission set authored').toBeTruthy(); + const ps = await catalogPermissionSet(ql, SCRATCH_SET); + expect(ps, 'scratch permission set authored').toBeTruthy(); const memberId = (await ql.findOne('sys_user', { where: { email: MEMBER }, context: SYS }))?.id; expect(memberId, 'member provisioned').toBeTruthy(); await ql.insert( diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index f80e7e1a79f..5c31472454f 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -30,6 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const SET = 'showcase_contributor'; @@ -96,8 +97,8 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const set = await ql.findOne('sys_permission_set', { where: { name: SET }, context: SYS }); - expect(set?.id, `the app's ${SET} set is seeded by the security bootstrap`).toBeTruthy(); + const set = await catalogPermissionSet(ql, SET); + expect(set, `the app's ${SET} set is served by the security catalog`).toBeTruthy(); // [ADR-0131 D2/D3] The position → set edge is the app's own declaration — // `contributor` names `showcase_contributor` in its `permissionSets` — and // the resolver reads it there. No `sys_position` row and no junction row diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts index 0c88b8c2dbc..53f32344ae8 100644 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -33,6 +33,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('sys_permission_set pure projection (ADR-0094)', () => { let stack: VerifyStack; @@ -45,6 +46,11 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + // [C3 stage 6b-2] The declared-set cases run on an EXISTING database: + // no boot writes these rows any more, but an earlier one did, and the + // data door still guards them. + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); afterAll(async () => { await stack?.stop(); }); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index b7268aebcbf..69a5627a97e 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet, ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; @@ -46,8 +47,8 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // Grant the zoo sets directly (system plumbing — the delegated-admin and // anchor gates are exercised through REAL authed requests below). const grant = async (userId: string, setName: string) => { - const ps = await ql.findOne('sys_permission_set', { where: { name: setName }, context: SYS }); - expect(ps?.id, `permission set ${setName} seeded`).toBeTruthy(); + const ps = await catalogPermissionSet(ql, setName); + expect(ps, `permission set ${setName} served by the security catalog`).toBeTruthy(); await ql.insert( 'sys_user_permission_set', { user_id: userId, permission_set: ps.name }, @@ -86,10 +87,12 @@ describe('showcase: ADR-0090 permission-model zoo', () => { }); // ── ADR-0090 D12: adminScope lands on the stored set ───────────────────── - it('persists the delegated-admin scope (adminScope → admin_scope JSON)', async () => { - const row = await ql.findOne('sys_permission_set', { where: { name: 'showcase_field_ops_delegate' }, context: SYS }); - expect(row, 'delegate set seeded').toBeTruthy(); - const scope = JSON.parse(row.admin_scope || 'null'); + it('serves the delegated-admin scope on the catalog definition (adminScope)', async () => { + // [C3 stage 6b-2] No row is written for a declared set; the catalog + // definition is what the delegated-admin gate reads. + const def: any = await catalogPermissionSet(ql, 'showcase_field_ops_delegate'); + expect(def, 'delegate set served by the security catalog').toBeTruthy(); + const scope = def.adminScope ?? null; expect(scope?.businessUnit).toBe('Field Operations'); expect(scope?.manageAssignments).toBe(true); expect(scope?.manageBindings).toBe(false); @@ -141,13 +144,17 @@ describe('showcase: ADR-0090 permission-model zoo', () => { await ql.insert('sys_position', { id: `pos_zoo_${name}`, name, label: name, managed_by: managedBy }, { context: SYS }); return ql.findOne('sys_position', { where: { name }, context: SYS }); }; + // [C3 stage 6b-2] The same for a permission-set row: no boot writes one any + // more, while the junction writes below (which grant nothing since stage 1) + // still name a row by id — the shape an existing database keeps. + const existingSetRow = (name: string) => ensureLegacyPermissionSetRow(ql, name, { managedBy: 'package' }); // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── it('a delegate assigns an allowlisted position inside their subtree', async () => { // Bind contributor → showcase_contributor first (admin action; the // delegate has manageBindings: false and must NOT be able to do this). const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); + const ps = await existingSetRow('showcase_contributor'); expect(pos?.id && ps?.id, 'contributor position + set rows').toBeTruthy(); const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, @@ -180,17 +187,16 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('the delegate cannot hand out a set OFF the allowlist (no self-escalation)', async () => { // showcase_auditor is not in assignablePermissionSets — direct grant refused. - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_permission_set', { user_id: delegateId, - permission_set: ps.name, + permission_set: 'showcase_auditor', }); expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); }); it('the delegate cannot re-compose positions (manageBindings: false)', async () => { const pos = await existingPositionRow('contributor', 'package'); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); + const ps = await existingSetRow('showcase_manager'); const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { position_id: pos.id, permission_set_id: ps.id, @@ -202,7 +208,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { const everyone = await existingPositionRow('everyone', 'platform'); expect(everyone, 'built-in everyone position row').toBeTruthy(); - const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); + const auditor = await existingSetRow('showcase_auditor'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: everyone.id, permission_set_id: auditor.id, @@ -213,7 +219,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { it('binding the guest-safe set to the guest anchor is accepted', async () => { const guest = await existingPositionRow('guest', 'platform'); expect(guest, 'built-in guest position row').toBeTruthy(); - const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); + const ps = await existingSetRow('showcase_guest_portal'); const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { position_id: guest.id, permission_set_id: ps.id, @@ -229,8 +235,7 @@ describe('showcase: ADR-0090 permission-model zoo', () => { // that; this proves the served pipeline actually denies the write now. it('FLS: a contributor can edit a project but NOT its budget fields', async () => { const plain = await ql.findOne('sys_user', { where: { email: 'zoo-plain@verify.test' }, context: SYS }); - const contribSet = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); - await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: contribSet.name }, { context: SYS }) + await ql.insert('sys_user_permission_set', { user_id: plain.id, permission_set: 'showcase_contributor' }, { context: SYS }) .catch(() => { /* already granted by an earlier step — idempotent */ }); const projects = await ql.find('showcase_project', { limit: 1, context: SYS }); diff --git a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts index a7178dcdd40..1b680ca3706 100644 --- a/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { getSharedShowcase } from './shared-showcase.js'; +import { ensureLegacyPermissionSetRow } from './catalog-permission-set.js'; describe('two-doors permission separation (ADR-0086 P2)', () => { let stack: VerifyStack; @@ -38,45 +39,18 @@ describe('two-doors permission separation (ADR-0086 P2)', () => { adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); + await ensureLegacyPermissionSetRow(ql, 'showcase_contributor', { managedBy: 'package' }); + await ensureLegacyPermissionSetRow(ql, 'member_default', { managedBy: 'platform' }); }, 60_000); const findSet = async (name: string) => (await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0]; - // ── 块1 — package door: draft → publish → materialize ──────────────────── - it('块1: publishing a package permission draft materializes a package-managed row', async () => { - // An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage), - // standing in for the package the studio package door edits. - const PKG = 'com.example.twodoors_ws'; - const NAME = 'twodoors_pkgset'; - - await protocol.saveMetaItem({ - type: 'permission', - name: NAME, - mode: 'draft', - packageId: PKG, - item: { - name: NAME, - label: 'Two Doors Set', - objects: { crm_lead: { allowRead: true, allowCreate: true } }, - }, - }); - - // Draft only — enforcement/admin-surface must NOT see it yet. - expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy(); - - const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME }); - expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy(); - expect(pub.materializeApplied.success).toBe(true); - - const row = await findSet(NAME); - expect(row, 'published set is now a real record').toBeTruthy(); - expect(row.managed_by).toBe('package'); - expect(row.package_id).toBe(PKG); - expect(JSON.parse(row.object_permissions || '{}')).toEqual({ - crm_lead: { allowRead: true, allowCreate: true }, - }); - }); + // [C3 stage 6b-2] 块1 — a package-door publish materializing a + // `managed_by:'package'` row — retired with the publish materializer + // (ADR-0131 D3: the boot and the publish write no catalog row). The 块2 + // cases below run on an EXISTING database, where the rows an earlier boot + // seeded still stand and the data door still guards them. // ── 块2 — admin door: artifact-backed rows now REFUSE the overlay (commit ee58392e1) ─ it('块2: an admin edit of a package-managed set is refused — no env overlay is minted (#6483)', async () => { diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index ec482ead452..c109eb5919a 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -52,6 +52,7 @@ import { } from './fixtures/attachments-fixture.js'; import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; @@ -123,8 +124,8 @@ async function boot(inside: boolean, email: string): Promise { const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; for (const name of GRANTS) { - const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); - expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); + const set = await catalogPermissionSet(ql, name); + expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set.name }, { context: { ...SYS } }); } if (!inside) token = await leaveOrganization(stack, email); From 9c7ecaf685ba884e49628e8ff54bf0245c3b513f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:26:03 +0000 Subject: [PATCH 2/4] test(dogfood): catalogPermissionSet answers any, as the findOne it replaces did (#15204 stage 6b-2, T2) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- packages/qa/dogfood/test/catalog-permission-set.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/catalog-permission-set.ts b/packages/qa/dogfood/test/catalog-permission-set.ts index b8a036cc26b..fd48332bbe4 100644 --- a/packages/qa/dogfood/test/catalog-permission-set.ts +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -14,10 +14,11 @@ import { securityCatalogReaderOf } from '@objectstack/core'; -export async function catalogPermissionSet( - ql: unknown, - name: string, -): Promise> | undefined> { +/** + * Typed `any`, as the `ql.findOne` it replaces at every call site was: a fixture + * asserts the definition exists and then reads its fields. + */ +export async function catalogPermissionSet(ql: unknown, name: string): Promise { const reader = securityCatalogReaderOf(ql); if (!reader) throw new Error('no security catalog is bound to this engine — is the SecurityPlugin booted?'); return (await reader.resolve('permission', name))?.definition; From 7477042350c9aa1813352cbbff89cb7acfba5b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:26:28 +0000 Subject: [PATCH 3/4] test(plugin-security): delete the platform-admin seeding suites, directly before the code they pin (#15204 stage 6b-2, T2) bootstrap-platform-admin.test.ts and bootstrap-platform-admin-seeded-provenance.test.ts pin bootstrapPlatformAdmin's seeding, which 6b-2 deletes; this PR lands directly before 6b-2. Their double-contract ledger entries go with them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- ...p-platform-admin-seeded-provenance.test.ts | 369 ------------------ .../src/bootstrap-platform-admin.test.ts | 143 ------- scripts/engine-double-contract.baseline.json | 8 - scripts/objectql-double-limit.baseline.json | 3 - 4 files changed, 523 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts deleted file mode 100644 index 92957e006b3..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the - * platform default permission sets, and what `os meta resync` then does with it. - * - * ## Why this file exists at all - * - * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — - * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every - * one of them hand-builds the pre-image. That is correct unit coverage of the - * BRANCH, and it is deliberately left untouched here. What none of them can - * answer is which pre-image the seeder itself produces, because a fake `ql` - * stores exactly the columns it is handed and a declared field `defaultValue` - * never runs. So the one row shape the platform actually creates was the one - * row shape nothing exercised, and the drift was invisible in both directions. - * - * This file closes that by seeding through the REAL shipped - * `defaultPermissionSets` against a real `ObjectQL` engine over a real - * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and - * `os meta resync` use, which hand the bare engine straight to the seeder. - * - * ## The ruling these assertions now pin (2026-08-15, Option A) - * - * The original version of this file recorded a MEASUREMENT and explicitly - * refused to endorse it: a seeded row stored `'admin'` (the declaration's - * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` - * and skipped every platform default set — the inverse of what #2705 built the - * flag for. It left the product question open: - * - * > are the platform default permission sets meant to be platform-owned - * > (and therefore resyncable), or env-authored (and therefore deliberately - * > left alone)? - * - * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: - * the platform default sets are platform-owned and resyncable, and the seeder - * stamps `managed_by: 'platform'` on its insert. This file no longer records an - * undecided state — it pins the ruling, on BOTH sides of the line the ruling - * drew: - * - * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real - * resync reconciles every default set. - * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries - * `'admin'` and is still SKIPPED, forever and on purpose. The ruling - * forbids a migration: a stored `'admin'` cannot be told apart from a - * genuine Setup takeover, so restamping could silently overwrite a real - * admin's edits on the next resync. Report, don't rewrite. - * - * Section 2 is the half that is easy to lose. Deleting it would leave the - * upgrade path — the one every existing deployment is actually on — with no - * coverage at all, and a later "cleanup" that restamps legacy rows would then - * go green. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -// `sys_user` is what the promotion half enumerates. Registered as the REAL -// declaration so its verdict ("no human users yet") is a genuine reading of an -// empty table rather than a table-missing error `tryFind` swallowed into `[]` — -// the two are indistinguishable from the outside and only one of them is the -// first-boot ordering this file means to reproduce. -import { SysUser } from '@objectstack/platform-objects/identity'; -// The REAL shipped seeder and the REAL shipped declarations. A local -// re-implementation of either would make this a test of the copy, which is -// precisely the gap the file exists to close. -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYSTEM_CTX = { isSystem: true }; - -const engines: ObjectQL[] = []; - -afterEach(async () => { - while (engines.length) { - try { - await engines.pop()?.destroy(); - } catch { - /* noop */ - } - } -}); - -/** - * A fresh engine on its own `:memory:` database, carrying the permission-set - * declaration passed in. `declaration` exists for section 3's counterfactual; - * every case in sections 1 and 2 boots the shipped declaration unmodified. - */ -async function boot(declaration: any = SysPermissionSet): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.security-objects', - name: 'Security Objects', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [declaration, SysUserPermissionSet, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The row as it is physically STORED, read at the driver past every engine-side - * projection. The resync branch reads the engine's view, so both are asserted - * where it matters — a default applied only on read and a value written on - * insert are different facts about the database, and only one of them survives - * a restart. - */ -async function storedRow(engine: ObjectQL, name: string): Promise { - const driver: any = (engine as any).getDriver('sys_permission_set'); - const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); - return rows[0]; -} - -/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ -async function rowViaEngine(engine: ObjectQL, name: string): Promise { - const rows: any[] = await (engine as any).find( - 'sys_permission_set', - { where: { name }, limit: 1 }, - { context: SYSTEM_CTX }, - ); - return rows[0]; -} - -/** - * A row exactly as an install before commit 712e185db holds it — written the way the old - * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes - * from the declaration's `defaultValue: 'admin'` by the very mechanism that - * produced it on every install created before the ruling. - * - * Reproducing the legacy pre-image through its original mechanism rather than - * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real - * declaration, and each case asserts the resulting value before relying on it, - * so a moved default shows up as a failure here instead of quietly turning the - * legacy pin into a test of something else. - */ -async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { - await (engine as any).insert( - 'sys_permission_set', - { - id: `ps_legacy_${ps.name}`, - name: ps.name, - label: ps.label ?? ps.name, - // A deliberately STALE payload: the resync would rewrite these if it ever - // touched the row, so section 2 can prove "left untouched" by content and - // not merely by a counter. - object_permissions: '{}', - field_permissions: '{}', - system_permissions: '[]', - row_level_security: '[]', - tab_permissions: '{}', - active: true, - }, - { context: SYSTEM_CTX }, - ); -} - -function collectingLogger() { - const info: string[] = []; - const warn: string[] = []; - return { - info, - warn, - logger: { - info: (m: string) => info.push(m), - warn: (m: string) => warn.push(m), - }, - }; -} - -describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { - // ─────────────────────────────────────────────────────────────────────────── - // 1. FORWARD — a fresh install after the ruling - // ─────────────────────────────────────────────────────────────────────────── - - it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { - // Every count below is relative to this set. If it were empty, "resync - // reconciled all of them" would be trivially true of nothing — the shape in - // which this whole file could pass while measuring air. - expect(defaultPermissionSets.length).toBeGreaterThan(0); - - const engine = await boot(); - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect(out.seeded).toBe(defaultPermissionSets.length); - }); - - it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { - // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored - // `'admin'` — the declaration's `defaultValue` — because the insert omitted - // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts`, which always has. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - // Physically stored, not merely projected on read: the value survives a - // restart, so every later boot reads it too. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - // …and the same value is what the seeder's own read sees, which is the one - // the resync condition branches on. - expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - it('so `os meta resync` now reconciles EVERY platform default set', async () => { - // The behaviour #2705 built the flag for, finally reachable: resynced == the - // whole shipped set, resyncSkipped 0. Asserted against the live length so - // the pin stays true if a default set is added or removed. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); - - it('a resync of a fresh install warns about nothing', async () => { - // The counterpart to the old measurement, which emitted one bogus - // "intentional override" warn per shipped set. Nothing is skipped now, so - // nothing is warned about. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([]); - }); - - it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { - // Counts alone cannot show the reconcile actually landed. Blank the payload - // on a seeded row, resync, and read the declaration back out of storage. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const driver: any = (engine as any).getDriver('sys_permission_set'); - await driver.knex('sys_permission_set') - .where({ name: 'admin_full_access' }) - .update({ system_permissions: '[]' }); - expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); - - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; - expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) - .toEqual(shipped.systemPermissions ?? []); - // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries - // no `managed_by`, which is what keeps a resync from restamping any row. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 2. LEGACY — a pre-ruling install, which keeps the skip forever - // ─────────────────────────────────────────────────────────────────────────── - - it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { - // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A - // stored `'admin'` is indistinguishable between "the old seeder's field - // default" and "an administrator took this set over in Setup", so it must - // never be reconciled on the strength of a guess. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - - // The pre-image really is what a pre-ruling install holds — asserted, not - // assumed, so this case cannot pass by testing some other row shape. - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - // An upgraded install: the seeder runs, finds the legacy row and leaves it, - // and inserts the remaining sets freshly stamped `'platform'`. - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - // The mixed shape an upgrade produces: everything the platform stamped is - // reconciled, the one legacy row is not. - expect(out.resyncSkipped).toBe(1); - expect(out.resynced).toBe(defaultPermissionSets.length - 1); - - // "Left untouched" proven by CONTENT, not just by a counter: the stale - // payload seeded above is still stale, and provenance was not restamped. - const row = await storedRow(engine, legacy.name); - expect(row.system_permissions).toBe('[]'); - expect(row.object_permissions).toBe('{}'); - expect(row.managed_by).toBe('admin'); - }); - - it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { - // The warn used to end "(intentional override)", which is false for exactly - // these rows: on a pre-ruling install the only writer may have been the - // seeder itself one call earlier. The reworded line states the provenance - // and the action and asserts nothing about anybody's intent. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([ - `[security] resync left ${legacy.name} untouched — row is admin-owned`, - ]); - // Pinned as a substring too, so a future reword cannot quietly reintroduce - // the intent claim in some other sentence shape. - expect(warn.join('\n')).not.toContain('intentional override'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 3. ATTRIBUTION — which link actually supplies the value - // ─────────────────────────────────────────────────────────────────────────── - - /** - * A counterfactual, and note it runs the OPPOSITE way round from the one this - * file carried before the ruling. Back then the seeder wrote no `managed_by`, - * so respelling the declared default flipped the stored value — which is what - * proved the default was the source. Now the seeder stamps explicitly, so the - * declared default must no longer be able to move the value at all. - * - * Predicted direction, written down before running: boot a CLONE of the - * declaration whose `managed_by` default is respelled to `'package'`, seed - * with the same unmodified seeder, and the row must STILL store `'platform'`. - * - * That one reading establishes what section 1 cannot on its own: the value - * comes from the seeder's stamp and not from a declaration that merely - * happens to agree with it. Without this case, respelling - * `defaultValue: 'admin'` to `'platform'` in the object file would make every - * assertion in section 1 pass with the stamp deleted. - */ - it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { - const respelled = { - ...SysPermissionSet, - fields: { - ...(SysPermissionSet as any).fields, - managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, - }, - }; - // Guard: the clone is a copy, and the shipped declaration is untouched by - // building it. Without this the case could be measuring a mutation it made. - // The shipped default stays `'admin'` deliberately — it is the right default - // for a set an admin creates in Setup, and the ruling changed the SEEDER, - // not the declaration. - expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); - - const engine = await boot(respelled); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - - // And the consequence still holds under a default that would otherwise have - // caused a skip: resync reconciles everything. - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts deleted file mode 100644 index 9701a38b887..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapPlatformAdmin — permission-set materialization, focused on the - * insert-once vs `resync` (#2705) split. - * - * The default boot path is insert-once: an existing default permission-set row - * is env-authored config and is never clobbered on restart (so admin Setup - * edits survive). That protection is CORRECT for prod but makes a dev source - * edit silently stale until a `--fresh` wipe. `os meta resync` passes - * `{ resync: true }` to reconcile platform-owned rows to the shipped dist - * without touching business data — while still refusing to overwrite a row an - * admin or a package has explicitly taken over. - */ - -import { describe, it, expect } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { applyManagedWriteDenies } from './managed-object-write-denies.js'; - -/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- - * promotion tables return empty, so promotion short-circuits and we assert the - * seed/resync outcome (carried on every return) directly. */ -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -/** Shipped declaration: member_default now grants `setup.access`. */ -const memberDefault = (over: Record = {}) => - ({ - name: 'member_default', - label: 'Member', - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access'], - ...over, - }) as any; - -const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); - -describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { - it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); - expect(r.resynced).toBe(0); - // Same row, and the shipped `setup.access` did NOT land — this is the - // #2705 boot behavior (protects admin edits; stale in the dev loop). - expect(row(ql)!.id).toBe('ps_old'); - expect(row(ql)!.system_permissions).toBe('[]'); - }); - - it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(1); - expect(r.resyncSkipped).toBe(0); - // Updated in place (no new insert), and the declaration is now live. - expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); - expect(row(ql)!.id).toBe('ps_old'); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { - const ql = makeQl([ - { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); - }); - - it('resync: leaves a package-owned row untouched', async () => { - const ql = makeQl([ - { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - }); - - it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { - const ql = makeQl([]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(row(ql)).toBeTruthy(); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - }); -}); - -// #3325 — the transform runs upstream in runBootstrap; here we confirm that once -// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin -// serializes the injected denies into the seeded object_permissions JSON. -describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { - const schemas = [ - { name: 'sys_sso_provider', managedBy: 'better-auth' }, - { name: 'crm_lead', managedBy: 'platform' }, - ]; - // member_default is a target set; give it the wildcard shape the real one has. - const enriched = () => { - const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; - applyManagedWriteDenies([set], schemas); - return set; - }; - - it('fresh DB: the injected deny serializes into object_permissions', async () => { - const ql = makeQl([]); - await bootstrapPlatformAdmin(ql, [enriched()]); - const perms = JSON.parse(row(ql)!.object_permissions); - expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); - expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied - }); - - it('resync: reconciles a stale platform-owned row that predates the deny', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); - expect(r.resynced).toBe(1); - expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); - }); -}); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index bdf92642bfb..fced54533c2 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -392,14 +392,6 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 25. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 9a37c3fd650..e8e3a67c64f 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -588,9 +588,6 @@ "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { "unjudged": 1 }, From a38b54f6f2be325a669e4cb3f42c87620d9fbb2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:26:05 +0000 Subject: [PATCH 4/4] test(plugin-auth): the grant-reader suites write the legacy platform-bucket rows they read The plugin-auth twins of the plugin-security grant-reader suites read the organization-less `sys_permission_set` rows the platform-admin bootstrap seeded (organization_admin to clone per organization; viewer_readonly to point a legacy id at). Once the boot writes no such row, those reads come back empty. Each suite now models the existing database it describes and writes the rows itself, before the bootstrap, through a plugin-auth copy of the plugin-security testkit: same shape, organization-less, idempotent. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .../__tests__/legacy-catalog-rows.testkit.ts | 61 +++++++++++++++++++ .../src/grant-readers-by-name.golden.test.ts | 3 + .../src/grant-readers-unnamed-grant.test.ts | 3 + 3 files changed, 67 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts diff --git a/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts new file mode 100644 index 00000000000..2da6b0c4017 --- /dev/null +++ b/packages/plugins/plugin-auth/src/__tests__/legacy-catalog-rows.testkit.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: the organization-less `sys_permission_set` rows an EARLIER + * release's platform-admin bootstrap seeded for the platform's default sets + * (the "platform bucket"). + * + * [C3 stage 6b-2] No boot writes these rows any more (ADR-0131 D3: the sets are + * served from the security catalog, and a grant names its set). A suite that + * models an EXISTING database — a grant written before the name column, whose + * `permission_set_id` points at one of these rows; an organization's copy cloned + * from the bucket — writes them itself, here, in the shape the retired seeder + * wrote: `managed_by: 'platform'`, active, the set's facets as JSON columns. + * Idempotent: a name that already has an organization-less row is left alone. + * + * The plugin-auth copy of plugin-security's testkit of the same name, in the + * same shape: a test-only helper is not exported across the package boundary. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; + +const SYS = { isSystem: true } as const; + +/** The legacy row id for a default set — stable, so a fixture can point a grant at it. */ +export function legacyBucketRowId(name: string): string { + return `ps_bucket_${name}`; +} + +/** Write the legacy platform-bucket row for each set; returns `name → id`. */ +export async function seedLegacyPlatformBucket( + engine: { find: (...args: any[]) => Promise; insert: (...args: any[]) => Promise }, + sets: readonly PermissionSet[], +): Promise> { + const ids: Record = {}; + for (const ps of sets) { + if (!ps.name) continue; + const existing = ((await engine.find('sys_permission_set', { where: { name: ps.name }, context: SYS })) ?? []) as any[]; + const own = existing.find((r) => !r.organization_id); + if (own?.id) { + ids[ps.name] = String(own.id); + continue; + } + const id = legacyBucketRowId(ps.name); + await engine.insert('sys_permission_set', { + id, + name: ps.name, + label: ps.label ?? ps.name, + description: (ps as any).description ?? null, + object_permissions: JSON.stringify(ps.objects ?? {}), + field_permissions: JSON.stringify(ps.fields ?? {}), + system_permissions: JSON.stringify(ps.systemPermissions ?? []), + row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), + tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, + active: true, + managed_by: 'platform', + }, { context: SYS }); + ids[ps.name] = id; + } + return ids; +} diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index 93ef93b5a02..7d43ddfc464 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -40,6 +40,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -153,6 +154,8 @@ async function standingByReader(posture: Posture): Promise { 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, ); } + // An existing database: the platform-bucket rows an earlier release seeded. + await seedLegacyPlatformBucket(engine, (manifest.permissions ?? []) as any[]); const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); expect(boot).toMatchObject({ adminPromoted: true }); return { engine };