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 }; 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/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts deleted file mode 100644 index 92957e006b3..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the - * platform default permission sets, and what `os meta resync` then does with it. - * - * ## Why this file exists at all - * - * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — - * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every - * one of them hand-builds the pre-image. That is correct unit coverage of the - * BRANCH, and it is deliberately left untouched here. What none of them can - * answer is which pre-image the seeder itself produces, because a fake `ql` - * stores exactly the columns it is handed and a declared field `defaultValue` - * never runs. So the one row shape the platform actually creates was the one - * row shape nothing exercised, and the drift was invisible in both directions. - * - * This file closes that by seeding through the REAL shipped - * `defaultPermissionSets` against a real `ObjectQL` engine over a real - * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and - * `os meta resync` use, which hand the bare engine straight to the seeder. - * - * ## The ruling these assertions now pin (2026-08-15, Option A) - * - * The original version of this file recorded a MEASUREMENT and explicitly - * refused to endorse it: a seeded row stored `'admin'` (the declaration's - * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` - * and skipped every platform default set — the inverse of what #2705 built the - * flag for. It left the product question open: - * - * > are the platform default permission sets meant to be platform-owned - * > (and therefore resyncable), or env-authored (and therefore deliberately - * > left alone)? - * - * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: - * the platform default sets are platform-owned and resyncable, and the seeder - * stamps `managed_by: 'platform'` on its insert. This file no longer records an - * undecided state — it pins the ruling, on BOTH sides of the line the ruling - * drew: - * - * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real - * resync reconciles every default set. - * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries - * `'admin'` and is still SKIPPED, forever and on purpose. The ruling - * forbids a migration: a stored `'admin'` cannot be told apart from a - * genuine Setup takeover, so restamping could silently overwrite a real - * admin's edits on the next resync. Report, don't rewrite. - * - * Section 2 is the half that is easy to lose. Deleting it would leave the - * upgrade path — the one every existing deployment is actually on — with no - * coverage at all, and a later "cleanup" that restamps legacy rows would then - * go green. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -// `sys_user` is what the promotion half enumerates. Registered as the REAL -// declaration so its verdict ("no human users yet") is a genuine reading of an -// empty table rather than a table-missing error `tryFind` swallowed into `[]` — -// the two are indistinguishable from the outside and only one of them is the -// first-boot ordering this file means to reproduce. -import { SysUser } from '@objectstack/platform-objects/identity'; -// The REAL shipped seeder and the REAL shipped declarations. A local -// re-implementation of either would make this a test of the copy, which is -// precisely the gap the file exists to close. -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYSTEM_CTX = { isSystem: true }; - -const engines: ObjectQL[] = []; - -afterEach(async () => { - while (engines.length) { - try { - await engines.pop()?.destroy(); - } catch { - /* noop */ - } - } -}); - -/** - * A fresh engine on its own `:memory:` database, carrying the permission-set - * declaration passed in. `declaration` exists for section 3's counterfactual; - * every case in sections 1 and 2 boots the shipped declaration unmodified. - */ -async function boot(declaration: any = SysPermissionSet): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.security-objects', - name: 'Security Objects', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [declaration, SysUserPermissionSet, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The row as it is physically STORED, read at the driver past every engine-side - * projection. The resync branch reads the engine's view, so both are asserted - * where it matters — a default applied only on read and a value written on - * insert are different facts about the database, and only one of them survives - * a restart. - */ -async function storedRow(engine: ObjectQL, name: string): Promise { - const driver: any = (engine as any).getDriver('sys_permission_set'); - const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); - return rows[0]; -} - -/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ -async function rowViaEngine(engine: ObjectQL, name: string): Promise { - const rows: any[] = await (engine as any).find( - 'sys_permission_set', - { where: { name }, limit: 1 }, - { context: SYSTEM_CTX }, - ); - return rows[0]; -} - -/** - * A row exactly as an install before commit 712e185db holds it — written the way the old - * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes - * from the declaration's `defaultValue: 'admin'` by the very mechanism that - * produced it on every install created before the ruling. - * - * Reproducing the legacy pre-image through its original mechanism rather than - * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real - * declaration, and each case asserts the resulting value before relying on it, - * so a moved default shows up as a failure here instead of quietly turning the - * legacy pin into a test of something else. - */ -async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { - await (engine as any).insert( - 'sys_permission_set', - { - id: `ps_legacy_${ps.name}`, - name: ps.name, - label: ps.label ?? ps.name, - // A deliberately STALE payload: the resync would rewrite these if it ever - // touched the row, so section 2 can prove "left untouched" by content and - // not merely by a counter. - object_permissions: '{}', - field_permissions: '{}', - system_permissions: '[]', - row_level_security: '[]', - tab_permissions: '{}', - active: true, - }, - { context: SYSTEM_CTX }, - ); -} - -function collectingLogger() { - const info: string[] = []; - const warn: string[] = []; - return { - info, - warn, - logger: { - info: (m: string) => info.push(m), - warn: (m: string) => warn.push(m), - }, - }; -} - -describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { - // ─────────────────────────────────────────────────────────────────────────── - // 1. FORWARD — a fresh install after the ruling - // ─────────────────────────────────────────────────────────────────────────── - - it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { - // Every count below is relative to this set. If it were empty, "resync - // reconciled all of them" would be trivially true of nothing — the shape in - // which this whole file could pass while measuring air. - expect(defaultPermissionSets.length).toBeGreaterThan(0); - - const engine = await boot(); - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect(out.seeded).toBe(defaultPermissionSets.length); - }); - - it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { - // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored - // `'admin'` — the declaration's `defaultValue` — because the insert omitted - // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts`, which always has. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - // Physically stored, not merely projected on read: the value survives a - // restart, so every later boot reads it too. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - // …and the same value is what the seeder's own read sees, which is the one - // the resync condition branches on. - expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - it('so `os meta resync` now reconciles EVERY platform default set', async () => { - // The behaviour #2705 built the flag for, finally reachable: resynced == the - // whole shipped set, resyncSkipped 0. Asserted against the live length so - // the pin stays true if a default set is added or removed. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); - - it('a resync of a fresh install warns about nothing', async () => { - // The counterpart to the old measurement, which emitted one bogus - // "intentional override" warn per shipped set. Nothing is skipped now, so - // nothing is warned about. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([]); - }); - - it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { - // Counts alone cannot show the reconcile actually landed. Blank the payload - // on a seeded row, resync, and read the declaration back out of storage. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const driver: any = (engine as any).getDriver('sys_permission_set'); - await driver.knex('sys_permission_set') - .where({ name: 'admin_full_access' }) - .update({ system_permissions: '[]' }); - expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); - - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; - expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) - .toEqual(shipped.systemPermissions ?? []); - // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries - // no `managed_by`, which is what keeps a resync from restamping any row. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 2. LEGACY — a pre-ruling install, which keeps the skip forever - // ─────────────────────────────────────────────────────────────────────────── - - it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { - // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A - // stored `'admin'` is indistinguishable between "the old seeder's field - // default" and "an administrator took this set over in Setup", so it must - // never be reconciled on the strength of a guess. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - - // The pre-image really is what a pre-ruling install holds — asserted, not - // assumed, so this case cannot pass by testing some other row shape. - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - // An upgraded install: the seeder runs, finds the legacy row and leaves it, - // and inserts the remaining sets freshly stamped `'platform'`. - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - // The mixed shape an upgrade produces: everything the platform stamped is - // reconciled, the one legacy row is not. - expect(out.resyncSkipped).toBe(1); - expect(out.resynced).toBe(defaultPermissionSets.length - 1); - - // "Left untouched" proven by CONTENT, not just by a counter: the stale - // payload seeded above is still stale, and provenance was not restamped. - const row = await storedRow(engine, legacy.name); - expect(row.system_permissions).toBe('[]'); - expect(row.object_permissions).toBe('{}'); - expect(row.managed_by).toBe('admin'); - }); - - it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { - // The warn used to end "(intentional override)", which is false for exactly - // these rows: on a pre-ruling install the only writer may have been the - // seeder itself one call earlier. The reworded line states the provenance - // and the action and asserts nothing about anybody's intent. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([ - `[security] resync left ${legacy.name} untouched — row is admin-owned`, - ]); - // Pinned as a substring too, so a future reword cannot quietly reintroduce - // the intent claim in some other sentence shape. - expect(warn.join('\n')).not.toContain('intentional override'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 3. ATTRIBUTION — which link actually supplies the value - // ─────────────────────────────────────────────────────────────────────────── - - /** - * A counterfactual, and note it runs the OPPOSITE way round from the one this - * file carried before the ruling. Back then the seeder wrote no `managed_by`, - * so respelling the declared default flipped the stored value — which is what - * proved the default was the source. Now the seeder stamps explicitly, so the - * declared default must no longer be able to move the value at all. - * - * Predicted direction, written down before running: boot a CLONE of the - * declaration whose `managed_by` default is respelled to `'package'`, seed - * with the same unmodified seeder, and the row must STILL store `'platform'`. - * - * That one reading establishes what section 1 cannot on its own: the value - * comes from the seeder's stamp and not from a declaration that merely - * happens to agree with it. Without this case, respelling - * `defaultValue: 'admin'` to `'platform'` in the object file would make every - * assertion in section 1 pass with the stamp deleted. - */ - it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { - const respelled = { - ...SysPermissionSet, - fields: { - ...(SysPermissionSet as any).fields, - managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, - }, - }; - // Guard: the clone is a copy, and the shipped declaration is untouched by - // building it. Without this the case could be measuring a mutation it made. - // The shipped default stays `'admin'` deliberately — it is the right default - // for a set an admin creates in Setup, and the ruling changed the SEEDER, - // not the declaration. - expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); - - const engine = await boot(respelled); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - - // And the consequence still holds under a default that would otherwise have - // caused a skip: resync reconciles everything. - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts deleted file mode 100644 index 9701a38b887..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapPlatformAdmin — permission-set materialization, focused on the - * insert-once vs `resync` (#2705) split. - * - * The default boot path is insert-once: an existing default permission-set row - * is env-authored config and is never clobbered on restart (so admin Setup - * edits survive). That protection is CORRECT for prod but makes a dev source - * edit silently stale until a `--fresh` wipe. `os meta resync` passes - * `{ resync: true }` to reconcile platform-owned rows to the shipped dist - * without touching business data — while still refusing to overwrite a row an - * admin or a package has explicitly taken over. - */ - -import { describe, it, expect } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { applyManagedWriteDenies } from './managed-object-write-denies.js'; - -/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- - * promotion tables return empty, so promotion short-circuits and we assert the - * seed/resync outcome (carried on every return) directly. */ -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -/** Shipped declaration: member_default now grants `setup.access`. */ -const memberDefault = (over: Record = {}) => - ({ - name: 'member_default', - label: 'Member', - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access'], - ...over, - }) as any; - -const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); - -describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { - it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); - expect(r.resynced).toBe(0); - // Same row, and the shipped `setup.access` did NOT land — this is the - // #2705 boot behavior (protects admin edits; stale in the dev loop). - expect(row(ql)!.id).toBe('ps_old'); - expect(row(ql)!.system_permissions).toBe('[]'); - }); - - it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(1); - expect(r.resyncSkipped).toBe(0); - // Updated in place (no new insert), and the declaration is now live. - expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); - expect(row(ql)!.id).toBe('ps_old'); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { - const ql = makeQl([ - { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); - }); - - it('resync: leaves a package-owned row untouched', async () => { - const ql = makeQl([ - { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - }); - - it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { - const ql = makeQl([]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(row(ql)).toBeTruthy(); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - }); -}); - -// #3325 — the transform runs upstream in runBootstrap; here we confirm that once -// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin -// serializes the injected denies into the seeded object_permissions JSON. -describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { - const schemas = [ - { name: 'sys_sso_provider', managedBy: 'better-auth' }, - { name: 'crm_lead', managedBy: 'platform' }, - ]; - // member_default is a target set; give it the wildcard shape the real one has. - const enriched = () => { - const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; - applyManagedWriteDenies([set], schemas); - return set; - }; - - it('fresh DB: the injected deny serializes into object_permissions', async () => { - const ql = makeQl([]); - await bootstrapPlatformAdmin(ql, [enriched()]); - const perms = JSON.parse(row(ql)!.object_permissions); - expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); - expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied - }); - - it('resync: reconciles a stale platform-owned row that predates the deny', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); - expect(r.resynced).toBe(1); - expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/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..fd48332bbe4 --- /dev/null +++ b/packages/qa/dogfood/test/catalog-permission-set.ts @@ -0,0 +1,64 @@ +// 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'; + +/** + * 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; +} + +/** + * 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 c50172d9a8c..dc2dd9ed776 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 @@ -73,6 +73,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; @@ -270,8 +271,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([ @@ -514,8 +515,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 63972bfb8be..c493df2f310 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 7492c9d7300..1ab0dbbf0c8 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); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index e9fb173732b..1d7795819a7 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -376,14 +376,6 @@ "why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.", "closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-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 6b790a043a5..29f86e9182c 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -576,9 +576,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/cleanup-package-permissions.test.ts": { "blind": 1 },