diff --git a/.changeset/15196-grant-readers-by-name-plugin-auth.md b/.changeset/15196-grant-readers-by-name-plugin-auth.md new file mode 100644 index 00000000000..3d9fe555114 --- /dev/null +++ b/.changeset/15196-grant-readers-by-name-plugin-auth.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-auth": patch +--- + +The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + +Clause-②: no + +- **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more. +- **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing. +- **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name. +- **Nothing to migrate.** diff --git a/.changeset/15196-grant-readers-by-name-plugin-security.md b/.changeset/15196-grant-readers-by-name-plugin-security.md new file mode 100644 index 00000000000..bf589cb0edf --- /dev/null +++ b/.changeset/15196-grant-readers-by-name-plugin-security.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-security": patch +--- + +The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + +Clause-②: no + +- **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change. +- **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name. +- **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant. +- The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above. +- **Nothing to migrate.** diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 0139548b7aa..b9a89df3115 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -119,6 +119,7 @@ import { import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js'; import type { CounterStore } from './rate-limit-storage.js'; +import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; import { isLastLocalCredentialHolder, LAST_LOCAL_CREDENTIAL_CODE, @@ -5270,8 +5271,14 @@ export class AuthManager { ); return; } + // [ADR-0131 D4] Already held? Asked BY NAME — the grant's + // `permission_set`, the reference a grant keeps once its id column is + // dropped. A self-registrant is a user created moments ago, so every + // grant it holds was written with its name; any of them naming this set, + // in any organization, settles the question and no second grant is + // written. const existing: any[] = await sys.find('sys_user_permission_set', { - where: { user_id: userId, permission_set_id: row.id }, + where: { user_id: userId, [GRANT_SET_NAME_FIELD]: row.name }, limit: 1, }); if (existing.length > 0) return; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 2359e3f6e69..f79026e085f 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1319,7 +1319,7 @@ describe('AuthPlugin', () => { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_user_permission_set: [ - { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_member: [], sys_organization: [], @@ -1572,7 +1572,7 @@ describe('AuthPlugin', () => { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_user_permission_set: [ - { id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null }, + { id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], // The platform admin already exists; the default-org bootstrap binds it // as `owner` on kernel:ready. A member-less seeded user is added later. @@ -1714,7 +1714,7 @@ describe('AuthPlugin', () => { // The admin's grant lands: the bootstrap creates the organization, and // that moment runs the pass — no restart, no app:seeded. - ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null }); + ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }); await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' }); await vi.waitFor(() => { expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' }); diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts index 486f1f99eee..94fdcd8e80d 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts @@ -65,8 +65,11 @@ type Row = Record; function rig(seed: Partial> = {}) { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], - // The platform admin `u1`, through the legacy grant anchor `single` reads. - sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }], + // The platform admin `u1`, through the legacy grant anchor `single` reads + // — by the set's name, which every platform grant writer stores (ADR-0131 D4). + sys_user_permission_set: [ + { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + ], sys_organization: [], sys_member: [], sys_migration: [], diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 1cc0185a960..3235d980f6d 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -76,7 +76,7 @@ function makeQl(seed: Partial> = {}) { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_user_permission_set: [ - { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }, + { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], sys_member: [], sys_organization: [], @@ -135,8 +135,8 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { it('picks the OLDEST cross-tenant admin grant', async () => { const ql = makeQl({ sys_user_permission_set: [ - { id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-02T00:00:00Z' }, - { id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-01T00:00:00Z' }, + { id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-02T00:00:00Z' }, + { id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-01T00:00:00Z' }, ], }); await ensureDefaultOrganization(ql); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 6ec9c36482a..f12ed5d23e4 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -82,6 +82,7 @@ import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; +import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; interface BootstrapLogger { info: (message: string, meta?: Record) => void; @@ -465,11 +466,18 @@ export async function ensureDefaultOrganization( if (adminPs.length === 0 || !adminPs[0].id) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } - const adminPsId = adminPs[0].id; + // [ADR-0131 D4] The grants NAMING the set (`permission_set`), not the + // grants carrying the row's id. A grant that names nothing yet — an + // upgraded deployment's, before the one-time backfill names it at + // `kernel:bootstrapped` — is not read as the admin: this helper CONFERS + // (an owner membership and the seeded rows), and a grant that names no set + // confers nothing. The answer is then `no_admin`, which decides nothing + // (`default-org-bootstrap-once.ts` records no decision on it), so a later + // trigger binds once the grant names its set. const adminGrants = await tryFind( ql, 'sys_user_permission_set', - { permission_set_id: adminPsId, organization_id: null }, + { [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: null }, 50, ); if (adminGrants.length === 0) { 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 new file mode 100644 index 00000000000..dfbca615362 --- /dev/null +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] Grant-equivalence goldens for the `plugin-auth` grant readers + * that key on `sys_user_permission_set.permission_set`, the grant's permission + * set BY NAME, instead of `permission_set_id`. + * + * The readers change their key and not their answer. This suite boots a real + * ObjectQL engine over the SQL driver with the real `SecurityPlugin` (its + * objects, and the engine hooks that keep the grant's two columns in step) and + * records the platform standing each reader in this package derives: + * + * - **the last-administrator guard** (`registerLastAdminGuard`), through the + * verdicts of the writes that probe its administrator enumeration: banning + * a member, banning every administrator at once, and — once the platform + * administrator is the only one left — revoking that standing by its grant + * row, by deactivating `admin_full_access`, or by banning the account; + * - **the default-organization bootstrap** (`ensureDefaultOrganization`): + * which account it finds as the platform administrator and binds as owner. + * + * Principals: the platform administrator (`single`: the promoted first user, a + * grant row; walled: the declared `OS_PLATFORM_OWNER_EMAIL` owner, no row), the + * organization administrator (the owner membership), a member and an agent + * (each holding a direct grant). Three postures: `single`, `group` and + * `isolated`. + * + * The golden below was recorded on the tree BEFORE the readers moved to the + * name (the PR record carries that run) and is unchanged after it. Pointing the + * platform administrator's grant name column at a different set turns it red — + * the PR record carries that ablation too. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SecurityPlugin, bootstrapPlatformAdmin, reconcileOrgAdminGrant } from '@objectstack/plugin-security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { ensureDefaultOrganization } from './ensure-default-organization.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_gra'; +const FUTURE = '2099-01-01T00:00:00.000Z'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +type Posture = 'single' | 'group' | 'isolated'; + +/** A write's verdict: `permitted`, or the refusal's code. */ +async function verdict(write: () => Promise): Promise { + try { + await write(); + return 'permitted'; + } catch (e) { + return `refused:${String((e as { code?: unknown })?.code ?? (e as Error)?.message)}`; + } +} + +async function standingByReader(posture: Posture): Promise> { + const walled = posture !== 'single'; + if (walled) { + process.env[POSTURE_ENV] = posture; + process.env[OWNER_ENV] = 'admin@gra.example'; + } + resetPlatformAdminEmailMemo(); + + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engines.push(engine); + + // The SecurityPlugin's own manifest supplies its objects and bootstrap sets. + let manifest: { objects?: unknown[]; permissions?: unknown[] } = {}; + const services: Record = { + manifest: { register: (m: any) => { manifest = m; } }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...((manifest.permissions as unknown[]) ?? [])], + }, + ...(walled + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } + : {}), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + const securityObjects = (manifest.objects ?? []).filter((o: any) => + ['sys_position', 'sys_user_position', 'sys_permission_set', 'sys_position_permission_set', 'sys_user_permission_set'] + .includes(o?.name)); + engine.registerApp({ + id: 'com.objectstack.qa.grant-readers-by-name-auth', + name: 'Grant readers by name (auth)', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + } as any); + await engine.syncSchemas(); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { + packageId: 'test.grant-readers-by-name', + logger: { info: () => undefined, warn: () => undefined }, + }); + const bootstrapSets = (manifest.permissions ?? []) as any[]; + + const orgCtx = { isSystem: true, tenantId: ORG }; + await engine.insert('sys_organization', { id: ORG, name: 'GRA Org', slug: 'gra' }, { context: SYS } as any); + const user = async (id: string, email: string, createdAt: string) => { + await engine.insert( + 'sys_user', { id, email, name: id, created_at: createdAt, email_verified: true, banned: false }, { context: SYS } as any, + ); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); + }; + await user('usr_admin', 'admin@gra.example', '2025-01-01T00:00:00.000Z'); + await user('usr_orgadmin', 'orgadmin@gra.example', '2025-02-01T00:00:00.000Z'); + await user('usr_member', 'member@gra.example', '2025-03-01T00:00:00.000Z'); + await user('usr_agent', 'agent@gra.example', '2025-04-01T00:00:00.000Z'); + + const boot = await bootstrapPlatformAdmin(engine, bootstrapSets); + expect(boot.adminPromoted).toBe(posture === 'single'); + if (walled) { + for (const name of ['organization_admin', 'organization_admin_no_bypass']) { + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; + await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); + } + } + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + { id: 'm_agent', user_id: 'usr_agent', organization_id: ORG, role: 'member', created_at: '2025-04-01T00:00:00.000Z' }, + ], { context: orgCtx } as any); + expect((await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture })).action).toBe('granted'); + const setId = async (name: string) => + String(((await engine.find('sys_permission_set', { where: { name, organization_id: null }, context: SYS })) as any[])[0]?.id); + await engine.insert('sys_user_permission_set', [ + { id: 'ups_member_viewer', user_id: 'usr_member', permission_set_id: await setId('viewer_readonly'), organization_id: ORG }, + { + id: 'ups_agent_read', user_id: 'usr_agent', permission_set_id: await setId('mcp_agent_data_read'), + organization_id: ORG, valid_until: FUTURE, + }, + ], { context: orgCtx } as any); + + // The default-organization bootstrap: which account it finds as the + // platform administrator, and binds as the owner of the Default Organization. + const defaultOrg = await ensureDefaultOrganization(engine as any, { + logger: { info: () => undefined, warn: () => undefined }, + }); + const defaultOwners = defaultOrg.defaultOrgId + ? ((await engine.find('sys_member', { + where: { organization_id: defaultOrg.defaultOrgId, role: 'owner' }, + context: SYS, + })) as any[]).map((m) => String(m.user_id)).sort() + : []; + + // The last-administrator guard's enumeration, read through its verdicts. + const sys = { context: SYS } as any; + const guard: Record = {}; + guard.banMember = await verdict(() => engine.update('sys_user', { id: 'usr_member', banned: true }, sys)); + guard.banEveryAdministrator = await verdict(() => engine.update( + 'sys_user', { banned: true }, { where: { id: { $in: ['usr_admin', 'usr_orgadmin'] } }, multi: true, ...sys }, + )); + // The organization administrator steps down — the default-organization bind + // above made the platform administrator an owner too, so that membership goes + // as well; what remains is the platform administrator's own standing. + guard.demoteOrganizationOwners = await verdict(() => engine.update( + 'sys_member', { role: 'member' }, { where: { role: 'owner' }, multi: true, ...sys }, + )); + const [adminGrant] = (await engine.find('sys_user_permission_set', { + where: { user_id: 'usr_admin', organization_id: null }, context: SYS, + })) as any[]; + guard.revokeSoleAdministratorGrant = adminGrant + ? await verdict(() => engine.delete('sys_user_permission_set', { where: { id: adminGrant.id }, ...sys })) + : 'no-grant-row'; + const [adminSet] = (await engine.find('sys_permission_set', { + where: { name: 'admin_full_access', organization_id: null }, context: SYS, + })) as any[]; + guard.deactivateAdministratorSet = await verdict(() => + engine.update('sys_permission_set', { id: adminSet.id, active: false }, sys)); + guard.banSoleAdministrator = await verdict(() => engine.update('sys_user', { id: 'usr_admin', banned: true }, sys)); + + return { + defaultOrganization: { + memberCreated: defaultOrg.memberCreated, + reason: defaultOrg.reason ?? null, + owners: defaultOwners, + }, + lastAdminGuard: guard, + }; +} + +describe('[ADR-0131 D4] grant readers by name (plugin-auth) — platform standing answers the recorded golden', () => { + for (const posture of ['single', 'group', 'isolated'] as const) { + it(`${posture}: the last-administrator guard and the default-organization bootstrap`, async () => { + const actual = await standingByReader(posture); + expect(actual).toEqual(GOLDEN[posture]); + }); + } +}); + +/** + * Recorded on the tree before the readers moved to the name; `group` and + * `isolated` were recorded identical, so they share one literal. `single` and the two walled postures differ where platform standing does: under + * `single` the platform administrator is a grant row, so revoking it or + * deactivating `admin_full_access` takes the last administrator away and is + * refused; under a wall the declared owner carries no grant row and outlives + * the set's deactivation, so only banning the account itself is refused. + */ +const SINGLE: unknown = { + defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin'] }, + lastAdminGuard: { + banMember: 'permitted', + banEveryAdministrator: 'refused:PERMISSION_DENIED', + demoteOrganizationOwners: 'permitted', + revokeSoleAdministratorGrant: 'refused:PERMISSION_DENIED', + deactivateAdministratorSet: 'refused:PERMISSION_DENIED', + banSoleAdministrator: 'refused:PERMISSION_DENIED', + }, +}; + +const WALLED: unknown = { + defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin'] }, + lastAdminGuard: { + banMember: 'permitted', + banEveryAdministrator: 'refused:PERMISSION_DENIED', + demoteOrganizationOwners: 'permitted', + revokeSoleAdministratorGrant: 'no-grant-row', + deactivateAdministratorSet: 'permitted', + banSoleAdministrator: 'refused:PERMISSION_DENIED', + }, +}; + +const GOLDEN: Record = { single: SINGLE, group: WALLED, isolated: WALLED }; diff --git a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts new file mode 100644 index 00000000000..5e349437166 --- /dev/null +++ b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] The grant that names nothing, as each `plugin-auth` grant + * reader treats it once the readers key on `sys_user_permission_set.permission_set`. + * + * A grant written before the name column existed carries `NULL` until the + * one-time backfill (`plugin-security`, `kernel:bootstrapped`) names it; on an + * upgraded deployment's first boot every grant is unnamed when the + * `kernel:ready` passes run, and the backfill leaves some unnamed for good (an + * id with no set row, or a set row of another organization). Each reader takes + * its fail-closed direction: + * + * - **the last-administrator guard** counts no administrator through it, and + * reads it as evidence that the environment is NOT fresh — so the bootstrap + * window never opens on an environment whose administrator holds one; + * - **the default-organization bootstrap** does not read it as the platform + * administrator: it confers an owner membership, and an unnamed grant + * confers nothing. + * + * And the guard's simulation of a write that re-points a grant's id without + * its name: the platform derives that name after the guard runs, so the guard + * reads it as taking the standing away — while an id echoed unchanged keeps it. + * + * Real ObjectQL over the SQL driver with the real `SecurityPlugin` (its objects + * and its name hooks); a grant is unnamed by writing `NULL` with the name hooks + * unbound, the state the backfill leaves. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SecurityPlugin, bootstrapPlatformAdmin } from '@objectstack/plugin-security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { ensureDefaultOrganization } from './ensure-default-organization.js'; + +const SYS = { context: { isSystem: true } } as any; +const NAME_HOOKS = 'plugin-security:grant-permission-set-name'; +const GUARD_PACKAGE = 'test.grant-readers-unnamed'; + +function registerGuard(engine: ObjectQL): void { + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { + packageId: GUARD_PACKAGE, + logger: { info: () => undefined, warn: () => undefined }, + }); +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface Rig { + engine: ObjectQL; +} + +/** `single` posture; the first user is promoted to platform administrator — the sole administrator. */ +async function soleGrantAnchoredAdmin(): Promise { + resetPlatformAdminEmailMemo(); + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engines.push(engine); + let manifest: { objects?: unknown[]; permissions?: unknown[] } = {}; + const services: Record = { + manifest: { register: (m: any) => { manifest = m; } }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...((manifest.permissions as unknown[]) ?? [])], + }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + engine.registerApp({ + id: 'com.objectstack.qa.grant-readers-unnamed-auth', + name: 'Grant readers — unnamed grant (auth)', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + ...(manifest.objects ?? []).filter((o: any) => + ['sys_position', 'sys_user_position', 'sys_permission_set', 'sys_position_permission_set', 'sys_user_permission_set'] + .includes(o?.name)), + ], + } as any); + await engine.syncSchemas(); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + registerGuard(engine); + for (const [id, createdAt] of [['usr_admin', '2025-01-01T00:00:00.000Z'], ['usr_member', '2025-03-01T00:00:00.000Z']]) { + await engine.insert( + 'sys_user', { id, email: `${id}@un.example`, name: id, created_at: createdAt, email_verified: true, banned: false }, SYS, + ); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS, + ); + } + const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]); + expect(boot).toMatchObject({ adminPromoted: true }); + return { engine }; +} + +const adminGrant = async (engine: ObjectQL): Promise => + ((await engine.find('sys_user_permission_set', { where: { user_id: 'usr_admin' }, ...SYS })) as any[])[0]; + +/** + * The state the backfill leaves on a grant it has not named. Written past both + * hooks that would refuse it: the name hooks, and the guard itself — clearing + * the sole administrator's grant name takes the standing away, which the guard + * refuses like any other revocation. The guard is bound again afterwards. + */ +async function unnameAdminGrant(engine: ObjectQL): Promise { + (engine as any).unregisterHooksByPackage(NAME_HOOKS); + (engine as any).unregisterHooksByPackage(GUARD_PACKAGE); + await engine.update('sys_user_permission_set', { permission_set: null }, { where: { user_id: 'usr_admin' }, multi: true, ...SYS }); + registerGuard(engine); + expect((await adminGrant(engine)).permission_set ?? null).toBeNull(); +} + +const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYS); + +describe('[ADR-0131 D4] a grant that names nothing — the last-administrator guard', () => { + it('control — named, the grant is the last administrator: banning its holder is refused', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + expect((await adminGrant(engine)).permission_set).toBe('admin_full_access'); + await expect(ban(engine, 'usr_admin')).rejects.toThrow(/last administrator/i); + }); + + it('unnamed, it counts no administrator but is evidence: the bootstrap window does not open', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + await unnameAdminGrant(engine); + await expect(ban(engine, 'usr_admin')).rejects.toThrow(/not the bootstrap window/i); + await expect(ban(engine, 'usr_admin')).rejects.toThrow(/no permission-set name yet/); + await expect(ban(engine, 'usr_member')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + }); + + it('clearing the sole administrator’s grant name is refused — the name is what the standing rides on', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + const grant = await adminGrant(engine); + await expect(engine.update('sys_user_permission_set', { id: grant.id, permission_set: null }, SYS)) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await adminGrant(engine)).permission_set).toBe('admin_full_access'); + }); + + it('re-pointing the sole administrator’s grant by id is read as taking the standing away', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + const grant = await adminGrant(engine); + const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[]; + await expect(engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id }, SYS)) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await adminGrant(engine)).permission_set).toBe('admin_full_access'); + }); + + it('echoing the grant’s own id keeps the standing: the write is permitted', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + const grant = await adminGrant(engine); + await engine.update( + 'sys_user_permission_set', + { id: grant.id, permission_set_id: grant.permission_set_id, reason: 'renewed' }, + SYS, + ); + expect((await adminGrant(engine)).reason).toBe('renewed'); + }); +}); + +describe('[ADR-0131 D4] a grant that names nothing — the default-organization bootstrap', () => { + it('control — named, the grant holder is bound as the Default Organization owner', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + const res = await ensureDefaultOrganization(engine as any, { logger: { info: () => undefined, warn: () => undefined } }); + expect(res.memberCreated).toBe(true); + }); + + it('unnamed, nobody is read as the platform administrator: `no_admin`, nothing is bound', async () => { + const { engine } = await soleGrantAnchoredAdmin(); + await unnameAdminGrant(engine); + const res = await ensureDefaultOrganization(engine as any, { logger: { info: () => undefined, warn: () => undefined } }); + expect(res).toMatchObject({ memberCreated: false, reason: 'no_admin' }); + expect(await engine.find('sys_member', { where: { user_id: 'usr_admin' }, ...SYS })).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/grant-set-name.ts b/packages/plugins/plugin-auth/src/grant-set-name.ts new file mode 100644 index 00000000000..670dce9fdec --- /dev/null +++ b/packages/plugins/plugin-auth/src/grant-set-name.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] How this package's grant readers read which permission set a + * `sys_user_permission_set` grant holds: BY NAME, from its `permission_set` + * column — the reference the grant keeps once ADR-0131 C8 drops + * `permission_set_id`. + * + * `plugin-security` writes the column (its engine hooks derive it from the id + * on every grant write, and a one-time backfill names the grants written before + * the column existed); this package only reads it. Internal: not re-exported + * from the package entry. + * + * A grant whose name is `NULL` or blank names no set. That is a grant written + * before the column existed on an upgraded deployment's first boot, before the + * backfill (`kernel:bootstrapped`) names it, or one the backfill could not name + * (an id with no set row, or a set row of another organization). Such a grant + * confers nothing through a by-name reader here; each reader states what it + * does with one in the direction that fails closed. + */ + +/** The grant's NAME reference to its permission set (ADR-0131 D4). */ +export const GRANT_SET_NAME_FIELD = 'permission_set'; + +/** The grant's id reference to the catalog row (dropped by ADR-0131 C8). */ +export const GRANT_SET_ID_FIELD = 'permission_set_id'; + +/** + * The permission set a stored grant names, or `undefined` for a grant that + * names none (`NULL` or blank). + */ +export function grantSetNameOf(row: unknown): string | undefined { + if (!row || typeof row !== 'object') return undefined; + const value = (row as Record)[GRANT_SET_NAME_FIELD]; + if (typeof value !== 'string') return undefined; + const name = value.trim(); + return name === '' ? undefined : name; +} diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts index 75303f14f2c..a0c83efa15a 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -112,6 +112,8 @@ const sysUserPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, user_id: { name: 'user_id', type: 'text' as const }, permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + // [ADR-0131 D4] The grant's set BY NAME — the column the guard reads. + permission_set: { name: 'permission_set', type: 'text' as const }, organization_id: { name: 'organization_id', type: 'text' as const }, valid_from: { name: 'valid_from', type: 'datetime' as const }, valid_until: { name: 'valid_until', type: 'datetime' as const }, @@ -236,7 +238,7 @@ describe('[#11663 L2] the enumeration counts CONFIG-derived administrators', () await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_grant', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_grant', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); @@ -311,7 +313,7 @@ describe('[#11663 L2] the FIFTH write shape is judged', () => { await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_owner', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_owner', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); @@ -403,7 +405,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); return engine; @@ -455,7 +457,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); @@ -476,7 +478,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); @@ -518,7 +520,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); @@ -537,7 +539,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: false }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a' }, + { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts index d4445d57633..12e99a4696e 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts @@ -87,6 +87,8 @@ const sysUserPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, user_id: { name: 'user_id', type: 'text' as const }, permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + // [ADR-0131 D4] The grant's set BY NAME — the column the guard reads. + permission_set: { name: 'permission_set', type: 'text' as const }, organization_id: { name: 'organization_id', type: 'text' as const }, valid_from: { name: 'valid_from', type: 'datetime' as const }, valid_until: { name: 'valid_until', type: 'datetime' as const }, @@ -174,7 +176,7 @@ async function seedGrantAdmin(engine: ObjectQL, userId = 'usr_grant'): Promise = { + [PS_ADMIN]: ADMIN_FULL_ACCESS, + ps_member: 'member_default', + ps_gone: 'retired_set', +}; + /** Every ban a real deprovision performs is a system-context write. */ async function ban(engine: ObjectQL, id: string): Promise { return engine.update('sys_user', { id, banned: true }, SYSTEM); @@ -280,11 +293,9 @@ async function seedUser( ); } if (extra.platformAdmin || extra.grant) { - await engine.insert( - 'sys_user_permission_set', - { id: `ups_${id}`, user_id: id, permission_set_id: PS_ADMIN, ...(extra.grant ?? {}) }, - SYSTEM, - ); + const grant: Record = { id: `ups_${id}`, user_id: id, permission_set_id: PS_ADMIN, ...(extra.grant ?? {}) }; + if (!('permission_set' in grant)) grant.permission_set = SET_NAME_OF[String(grant.permission_set_id)]; + await engine.insert('sys_user_permission_set', grant, SYSTEM); } } @@ -1916,9 +1927,9 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo await expect(ban(engine, 'usr_platform')).rejects.toThrow(/recognises NO administrator/); await expect(ban(engine, 'usr_platform')).rejects.toThrow(/not the bootstrap window/i); // Holder and target are both quoted, so an operator can go find the row. + // [ADR-0131 D4] The target is the set the grant NAMES — its reference. await expect(ban(engine, 'usr_platform')).rejects.toThrow(/'usr_platform'/); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(new RegExp(PS_ADMIN)); - await expect(ban(engine, 'usr_platform')).rejects.toThrow(new RegExp(ADMIN_FULL_ACCESS)); + await expect(ban(engine, 'usr_platform')).rejects.toThrow(`'usr_platform' → '${ADMIN_FULL_ACCESS}'`); await expect(ban(engine, 'usr_platform')).rejects.toThrow(/ADR-0135 D5\.2/); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index e4e0e67db04..6121ee3ed17 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -44,7 +44,8 @@ * that is not itself an identity table. "Who is a platform admin" is * resolved by NAME: the first step of `resolveAdminUserIds` looks the * permission set up as `where: { name: 'admin_full_access' }` and only then - * reads the grants pointing at its id. Remove that row, call it something + * reads the grants naming it ([ADR-0131] D4: the grant's `permission_set` + * column, no longer the row's id). Remove that row, call it something * else, or (ADR-0049, since `active` became a resolution-time predicate) * switch it off, and every grant, every `sys_user` row and every * `sys_member` row survives untouched while nobody is a platform admin any @@ -329,6 +330,7 @@ import { } from '@objectstack/core'; import { isOrgAdminGrade } from './invitation-role-cap.js'; +import { GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-set-name.js'; /** `sys_user_permission_set` has no `SystemObjectName` member; it is spelled once, here. */ const USER_PERMISSION_SET = 'sys_user_permission_set'; @@ -615,6 +617,33 @@ function applyPending( return { ...row, ...pending.patch }; } +/** + * [ADR-0131 D4] The permission set a grant row names once `pending` lands, as + * the enumeration reads it — or `undefined` when it names none, or when this + * guard cannot know what it will name. + * + * `simulated` is `applyPending(raw, …)`. The one case it gets wrong is a + * write that RE-POINTS the grant's id without carrying its name: the + * platform derives the new name from the new id in an engine hook that runs + * after this guard, so the simulated row still shows the old name. Such a row + * is read as naming nothing — standing taken away, the one direction this + * simulation is allowed to round in. An id echoed unchanged keeps its name. + */ +function simulatedGrantSetName( + raw: Record, + simulated: Record, + pending: PendingStandingWrite | undefined, +): string | undefined { + const patch = pending?.table === USER_PERMISSION_SET && pending.patch ? pending.patch : undefined; + const id = toId(raw.id); + if (patch && id && pending!.ids.has(id) && !(GRANT_SET_NAME_FIELD in patch)) { + for (const key of [GRANT_SET_ID_FIELD, 'permissionSetId']) { + if (key in patch && toId(patch[key]) !== toId(raw[GRANT_SET_ID_FIELD] ?? raw.permissionSetId)) return undefined; + } + } + return grantSetNameOf(simulated); +} + /** * Which keys of a `sys_member` payload can move the administrator enumeration. * The `sys_member` half of `resolveAdminUserIds` reads exactly two columns — @@ -631,8 +660,15 @@ export const MEMBER_STANDING_KEYS = ['role', 'user_id', 'userId'] as const; * at, whose it is, whether it is org-scoped, and its ADR-0091 validity window * — every column the grant half of the enumeration consumes, in both the * snake_case and camelCase spellings the readers already tolerate. + * + * [ADR-0131 D4] The enumeration reads WHICH set by the grant's name, + * `permission_set`, so a write touching that column alone moves it. The id + * spellings stay: a write that re-points the id carries the name the platform + * will derive from it only after this guard has run, so the guard reads such + * a write as taking the standing away (`simulatedGrantSetName`). */ export const GRANT_STANDING_KEYS = [ + 'permission_set', 'permission_set_id', 'permissionSetId', 'user_id', @@ -939,6 +975,17 @@ export function registerLastAdminGuard( // list would read as absent here and absent means ACTIVE — the guard would // model an environment in which no set is ever deactivated and permit the // one write that empties it. + // + // [ADR-0131 D4] A grant holds `admin_full_access` BY NAME — its + // `permission_set` column — so the grant half reads the grants NAMING the + // set, and the set rows only for whether the name is in effect: it is when + // at least one row bearing it survives the pending write still named so, + // and none of them is deactivated. With one row (the `single` catalog) + // that is exactly the row's own verdict; where a name has several rows + // (another organization's copy), one switched off is read as switching the + // name off — an under-count, the direction this guard may round in. A + // grant that names nothing is not counted at all: it holds no set by name, + // and `refuseIfEmptiedRatherThanFresh` reads it as evidence instead. const legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()); const sets = legacyGrantAnchorRetired ? [] @@ -946,20 +993,23 @@ export function registerLastAdminGuard( where: { name: ADMIN_FULL_ACCESS }, fields: ['id', 'name', 'active'], }); - const adminSetIds: string[] = []; + let adminSetNamed = false; + let adminSetSwitchedOff = false; for (const rawSet of sets) { const set = applyPending(rawSet, pending, SystemObjectName.PERMISSION_SET); if (!set) continue; // removed outright by the pending write if (set.name !== ADMIN_FULL_ACCESS) continue; // renamed away — the row survives, the meaning does not // Deactivated — the row survives under its own name and grants nothing, // judged by the SAME predicate `resolveAuthzContext` resolves with. - if (!isRowActive(set)) continue; - const sid = toId(set.id); - if (sid) adminSetIds.push(sid); + if (!isRowActive(set)) { + adminSetSwitchedOff = true; + continue; + } + adminSetNamed = true; } - if (adminSetIds.length > 0) { + if (adminSetNamed && !adminSetSwitchedOff) { const links = await scan(op, USER_PERMISSION_SET, { - where: { permission_set_id: { $in: adminSetIds } }, + where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); for (const raw of links) { const link = applyPending(raw, pending, USER_PERMISSION_SET); @@ -967,9 +1017,10 @@ export function registerLastAdminGuard( if (!link) continue; // Re-pointed away from `admin_full_access` — the row survives, the // standing does not. Re-tested rather than assumed, because the scan's - // own `where` only proved where the grant pointed BEFORE the write. - const setId = toId(link.permission_set_id ?? link.permissionSetId); - if (setId !== undefined && !adminSetIds.includes(setId)) continue; + // own `where` only proved what the grant named BEFORE the write; a + // re-pointed id whose name this guard cannot know yet reads as naming + // nothing (`simulatedGrantSetName`). + if (simulatedGrantSetName(raw, link, pending) !== ADMIN_FULL_ACCESS) continue; // An org-SCOPED grant makes a tenant admin, not the environment's // break-glass admin — the same distinction `resolveAuthzContext` draws // when it derives `platform_admin` from the unscoped grant only. This @@ -1116,6 +1167,17 @@ export function registerLastAdminGuard( * and every later ban, delete and downgrade sails through unguarded. So the * deactivated row with unscoped, in-window grants still pointing at it is * read as the SAME evidence, with its own remedy — re-activate it. + * + * [ADR-0131 D4] Read BY NAME, as the enumeration is: a dangling grant is one + * whose `permission_set` names a set no `sys_permission_set` row carries any + * more, and a grant that names NOTHING (`grantSetNameOf` is empty) is + * evidence too — the enumeration counts no such grant, so an environment + * whose administrators hold only such grants reads as empty, and the + * bootstrap window must not open on it. On a fresh install every grant names + * its set (the platform writes the name with the id), so the fresh answer is + * unchanged. Such a grant is an upgraded deployment's, before the one-time + * backfill names it at `kernel:bootstrapped`, or one the backfill could not + * name; refusing is the fail-closed reading of both. */ const refuseIfEmptiedRatherThanFresh = async (op: GuardedOp): Promise => { // [#11663 L5] Both refusals below name ONE remedy — put the @@ -1136,20 +1198,19 @@ export function registerLastAdminGuard( : ''; const sets = await scan(op, SystemObjectName.PERMISSION_SET, { fields: ['id', 'name', 'active'] }); const known = new Set(); - const deactivatedAdminSetIds: string[] = []; + let adminSetDeactivated = false; for (const row of sets) { - const sid = toId(row.id); - if (!sid) continue; - known.add(sid); - if (row.name === ADMIN_FULL_ACCESS && !isRowActive(row)) deactivatedAdminSetIds.push(sid); + if (typeof row.name !== 'string' || row.name === '') continue; + known.add(row.name); + if (row.name === ADMIN_FULL_ACCESS && !isRowActive(row)) adminSetDeactivated = true; } // The deactivated-break-glass case, checked before the dangling one: it has // a precise diagnosis and a one-click remedy, so it must not be reported as // the vaguer "something was deleted" story. - if (deactivatedAdminSetIds.length > 0) { + if (adminSetDeactivated) { const held = await scan(op, USER_PERMISSION_SET, { - where: { permission_set_id: { $in: deactivatedAdminSetIds } }, + where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); const nowMs = Date.now(); const stranded = held.filter( @@ -1176,29 +1237,29 @@ export function registerLastAdminGuard( } // With no permission set at all every grant is dangling, and `$nin: []` is // not a predicate every driver agrees on — so that case reads unfiltered - // and lets the in-memory re-test below do the work. + // and lets the in-memory re-test below do the work. `$nin` keeps the rows + // whose name is NULL (#5298), which are evidence here as well. const candidates = await scan(op, USER_PERMISSION_SET, { - ...(known.size > 0 ? { where: { permission_set_id: { $nin: [...known] } } } : {}), + ...(known.size > 0 ? { where: { [GRANT_SET_NAME_FIELD]: { $nin: [...known] } } } : {}), }); const now = Date.now(); const dangling: string[] = []; for (const link of candidates) { - const setId = toId(link.permission_set_id ?? link.permissionSetId); - // A grant that points at nothing names no deleted set. - if (!setId) continue; + const name = grantSetNameOf(link); // `$nin` is NULL-safe on this engine (#5298) and the drivers differ on // the edges, so danglingness is re-tested in memory rather than trusted // from the `where` — the same discipline the enumeration applies to - // `permission_set_id` and `name`. - if (known.has(setId)) continue; + // the grant's name and the set's `name`. + if (name !== undefined && known.has(name)) continue; // An org-SCOPED grant never conferred the environment's break-glass // standing, and an out-of-window one never conferred it either, so // neither is evidence that this environment once had a platform admin. if (link.organization_id ?? link.organizationId) continue; if (!isGrantActive(link, now)) continue; const uid = toId(link.user_id ?? link.userId); - dangling.push(uid ? `'${uid}' → '${setId}'` : `'${setId}'`); + const target = name !== undefined ? `'${name}'` : '(no permission-set name yet)'; + dangling.push(uid ? `'${uid}' → ${target}` : target); } if (dangling.length === 0) return; // a genuinely fresh environment @@ -1210,8 +1271,8 @@ export function registerLastAdminGuard( throw refuse( `Refusing this ${words.noun}: this environment recognises NO administrator, and this is not ` + `the bootstrap window — ${dangling.length} unscoped, in-window '${USER_PERMISSION_SET}' ` + - `grant(s) still point at a '${SystemObjectName.PERMISSION_SET}' row that no longer exists ` + - `(${dangling.join(', ')}). That is the state a DELETED '${ADMIN_FULL_ACCESS}' ` + + `grant(s) still name a permission set that no '${SystemObjectName.PERMISSION_SET}' row ` + + `carries any more, or name none yet (${dangling.join(', ')}). That is the state a DELETED '${ADMIN_FULL_ACCESS}' ` + 'permission-set row leaves behind: it un-makes every platform admin at once, and ' + 'reading the resulting emptiness as "no administrator to protect" would switch this guard ' + `off for every other write too (${BREAK_GLASS_CITATION}). Restore the ` + diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index d7206df6847..0b56f2e363a 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -124,6 +124,8 @@ const sysUserPermissionSet = { id: { name: 'id', type: 'text' as const, primaryKey: true }, user_id: { name: 'user_id', type: 'text' as const }, permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + // [ADR-0131 D4] The grant's set BY NAME — the column the guard reads. + permission_set: { name: 'permission_set', type: 'text' as const }, organization_id: { name: 'organization_id', type: 'text' as const }, valid_from: { name: 'valid_from', type: 'datetime' as const }, valid_until: { name: 'valid_until', type: 'datetime' as const }, @@ -349,7 +351,7 @@ async function makePlatformAdmin(h: Harness, userId: string): Promise { } await h.engine.insert( 'sys_user_permission_set', - { id: `ups_${userId}`, user_id: userId, permission_set_id: PS_ADMIN }, + { id: `ups_${userId}`, user_id: userId, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS }, SYSTEM, ); } diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts index de077e7ba82..3d26761e962 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts @@ -1047,18 +1047,28 @@ describe('[#11670] `single` posture is carved out', () => { }); }); - it('the read ORDER and the objects read are unchanged under `single`', async () => { - // The rest of the multiset: same objects, same order, same predicates — - // only the two reads named in the deviation above differ, and only in - // `limit`/`$in`. + it('the read ORDER and the objects read under `single`', async () => { + // The rest of the multiset: same objects, same order — only the two reads + // named in the deviation above differ, and only in `limit`/`$in`. + // + // [ADR-0131 D4] Each grant question is now asked BY NAME, plus one read by + // id that reaches only the pair's grants that name nothing yet (written + // before the name column, not yet backfilled) — for the restriction alone. const stub = seedSingle(); await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'single' }); expect(stub.findCalls.map((c) => c.object)).toEqual([ 'sys_permission_set', // grant-target resolution (limit 1, unchanged) 'sys_member', // does the pair qualify 'sys_permission_set', // superseded-variant ids (widened — see above) - 'sys_user_permission_set', // superseded grants for the pair (widened) - 'sys_user_permission_set', // does the grant already exist (scalar, unchanged) + 'sys_user_permission_set', // superseded grants for the pair, by name + 'sys_user_permission_set', // …and the pair's unnamed ones, by every copy's id + 'sys_user_permission_set', // does the grant already exist, by name + 'sys_user_permission_set', // …or an unnamed one carrying the id it would write + ]); + const grantReads = stub.findCalls.filter((c) => c.object === 'sys_user_permission_set'); + expect(grantReads.map((c) => c.where?.permission_set ?? null)).toEqual([ + 'organization_admin', null, 'organization_admin_no_bypass', null, ]); + expect(grantReads[3].where.permission_set_id).toEqual({ $in: ['ps_org_admin_nb'] }); }); }); diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index 646d8fb359f..69aa8115e38 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -55,6 +55,7 @@ import { seedCtx, SEED_ORGANIZATION_SCAN_LIMIT, } from './per-organization-catalog.js'; +import { GRANT_SET_ID_FIELD, GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; const SYSTEM_CTX = { isSystem: true } as const; @@ -573,6 +574,55 @@ async function resolvePermissionSetIdsForName( return ids; } +/** + * [ADR-0131 D4] The grant rows of `where` that hold the set `name` — read BY + * NAME (`sys_user_permission_set.permission_set`), which reaches every copy of + * the set at once: a name is the reference, whichever organization's row the + * grant's id was written against. + * + * Plus — to RESTRICT only — the rows of `where` that name nothing yet + * (`grantSetNameOf` is empty) and whose id is one of `unnamedIds`: a grant + * written before the name column existed, on an upgraded deployment's first + * boot before the backfill names it (`kernel:ready` runs ahead of the + * backfill's `kernel:bootstrapped`), or one the backfill could not name (an + * id on another organization's row). A revocation reaches it, and so does + * the duplicate check a grant insert makes; nothing is ever GRANTED through + * it. The id leg goes when ADR-0131 C8 counts the unnamed grants and drops + * the column. + * + * Rows come back once each, named ones first. + */ +async function grantsHoldingName( + ql: any, + where: Record, + name: string, + unnamedIds: readonly string[], + limit: number, + logger?: MaybeLogger, +): Promise { + const named = await tryFind(ql, 'sys_user_permission_set', { ...where, [GRANT_SET_NAME_FIELD]: name }, limit, logger); + const unnamed = unnamedIds.length > 0 + ? (await tryFind( + ql, + 'sys_user_permission_set', + { ...where, [GRANT_SET_ID_FIELD]: { $in: [...unnamedIds] } }, + limit, + logger, + )).filter((row) => grantSetNameOf(row) === undefined) + : []; + const seen = new Set(); + const out: any[] = []; + for (const row of [...named, ...unnamed]) { + const key = row?.id === undefined || row?.id === null ? undefined : String(row.id); + if (key !== undefined) { + if (seen.has(key)) continue; + seen.add(key); + } + out.push(row); + } + return out; +} + /** @@ -716,16 +766,16 @@ export async function reconcileOrgAdminGrant( // whichever copy that posture resolved — a narrow match would converge on // nothing and leave the superseded bits in force, which is the F2 outcome // this leg is the close-out for. + // + // [ADR-0131 D4] Matched BY NAME, which is every copy at once; the copies' + // ids still reach a grant that names nothing yet ({@link grantsHoldingName}). const supersededSetIds = await resolvePermissionSetIdsForName(ql, supersededSetName, logger); - if (supersededSetIds.length > 0) { - const stale = await tryFind( + { + const stale = await grantsHoldingName( ql, - 'sys_user_permission_set', - { - user_id: userId, - organization_id: orgId, - permission_set_id: { $in: supersededSetIds }, - }, + { user_id: userId, organization_id: orgId }, + supersededSetName, + supersededSetIds, 5, logger, ); @@ -750,6 +800,14 @@ export async function reconcileOrgAdminGrant( // every copy. Under `single` both spell the same scalar predicate against the // same single id, in the same position, so the carve-out issues exactly the // reads it issued before. + // + // [ADR-0131 D4] Both now ask by NAME — a grant holds a set by its name, so + // "does the pair already hold it" and "does the pair hold any of it" read the + // same column ({@link grantsHoldingName}). What stays narrow is the row a NEW + // grant points its id at (this organization's own copy, above); a pair that + // already holds the set by name through another copy is not handed a second + // grant. A grant that names nothing yet is still reached through the id it + // would duplicate, so the check never inserts a row the unique index refuses. if (shouldGrant) { if (!permSetId) { // Walled only (the `single` early return above already fired). The @@ -760,17 +818,26 @@ export async function reconcileOrgAdminGrant( // lose the capability keeps it. return { action: 'skipped', reason: 'permission_set_missing' }; } - const existingGrants = await tryFind( + const existingGrants = await grantsHoldingName( ql, - 'sys_user_permission_set', - { user_id: userId, organization_id: orgId, permission_set_id: permSetId }, + { user_id: userId, organization_id: orgId }, + grantSetName, + [permSetId], 5, logger, ); if (existingGrants.length > 0) { - // Deduplicate stale duplicates if any slipped through. - for (const extra of existingGrants.slice(1)) { - if (extra?.id) await tryDelete(ql, 'sys_user_permission_set', String(extra.id), logger); + // Deduplicate stale duplicates if any slipped through — rows repeating + // an earlier row's own id. A grant against another copy is a different + // row, not a duplicate, and is never deleted here (#11670). + const kept = new Set(); + for (const row of existingGrants) { + const rowSetId = String(row?.[GRANT_SET_ID_FIELD] ?? ''); + if (!kept.has(rowSetId)) { + kept.add(rowSetId); + continue; + } + if (row?.id) await tryDelete(ql, 'sys_user_permission_set', String(row.id), logger); } return { action: 'noop' }; } @@ -815,21 +882,19 @@ export async function reconcileOrgAdminGrant( // a demotion that could not see it would leave the capability in force. ⛔ It // revokes; it never re-points or adopts a row for someone who still // qualifies. + // + // [ADR-0131 D4] By NAME, every copy at once; the copies' ids still reach a + // grant that names nothing yet, so no demotion leaves one standing + // ({@link grantsHoldingName}). const revocableSetIds = await resolvePermissionSetIdsForName(ql, grantSetName, logger); - const existingGrants = - revocableSetIds.length > 0 - ? await tryFind( - ql, - 'sys_user_permission_set', - { - user_id: userId, - organization_id: orgId, - permission_set_id: { $in: revocableSetIds }, - }, - 5, - logger, - ) - : []; + const existingGrants = await grantsHoldingName( + ql, + { user_id: userId, organization_id: orgId }, + grantSetName, + revocableSetIds, + 5, + logger, + ); if (existingGrants.length === 0) { return { action: 'noop' }; } @@ -934,14 +999,19 @@ export async function backfillOrgAdminGrants( // Also revoke any organization_admin grant pointing at a (user, org) // pair with NO membership row left (orphaned grants from deletes // that fired before this hook existed). - const grantSetIds = [...permSetIds, ...supersededIds]; - const allGrants = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: { $in: grantSetIds } }, - limit, - logger, - ); + // + // [ADR-0131 D4] The grants of either variant, by NAME — plus, through the + // copies' ids, the ones that name nothing yet (this sweep runs at + // `kernel:ready`, ahead of the name backfill), so an orphan is reached + // either way ({@link grantsHoldingName}). + const allGrants = [ + ...(await grantsHoldingName( + ql, {}, orgAdminSetNameForPosture(posture, suppressUnbounded), permSetIds, limit, logger, + )), + ...(await grantsHoldingName( + ql, {}, supersededOrgAdminSetName(posture, suppressUnbounded), supersededIds, limit, logger, + )), + ]; for (const g of allGrants) { const userId = String(g?.user_id ?? ''); const orgId = String(g?.organization_id ?? ''); 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 04f396c2b9a..882b4f6f9dd 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 @@ -334,6 +334,7 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC id: 'ups_legacy', user_id: 'usr_person0', permission_set_id: adminPsId, + permission_set: 'admin_full_access', organization_id: null, }, { context: SYSTEM_CTX }, diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index 74300e52faf..6ab7ae983cb 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -239,6 +239,7 @@ async function seedTenant( id: 'ups_zzz_founder', user_id: 'usr_founder', permission_set_id: ADMIN_PS_ID, + permission_set: 'admin_full_access', organization_id: unscopedOrganizationId, }, { context: SYSTEM_CTX }, @@ -259,6 +260,7 @@ async function seedTenant( id: `ups_org_${n}`, user_id: `usr_orgadmin_${n}`, permission_set_id: ADMIN_PS_ID, + permission_set: 'admin_full_access', organization_id: `org_${n}`, }, { context: SYSTEM_CTX }, @@ -419,7 +421,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = ); await ql.insert( 'sys_user_permission_set', - { id: 'ups_system', user_id: 'usr_system', permission_set_id: ADMIN_PS_ID, organization_id: null }, + { id: 'ups_system', user_id: 'usr_system', permission_set_id: ADMIN_PS_ID, permission_set: 'admin_full_access', organization_id: null }, { context: SYSTEM_CTX }, ); await seedUser(ql, 'usr_real', 'real@tenant.example', '2026-03-01T00:00:00.000Z', true); 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 2c9667b2efb..f91b0163515 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 @@ -755,6 +755,7 @@ describe('#16682 — the promotion target is chosen, not sampled', () => { id: 'ups_legacy', user_id: 'usr_person0', permission_set_id: sets[0].id, + permission_set: 'admin_full_access', organization_id: null, }, { context: SYSTEM_CTX }, diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index 4c2b9c13e92..18e2a952443 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -371,7 +371,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) makeQl({ sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], users: [user('u_legacy', 'legacy-admin@corp.example', '2026-01-01T00:00:00Z', { email_verified: true })], - grants: [{ id: 'ups_1', user_id: 'u_legacy', permission_set_id: 'ps_admin', organization_id: null }], + grants: [{ id: 'ups_1', user_id: 'u_legacy', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], }); it('⛔ a seeded legacy grant produces NO deprecation line any more — the window is closed', async () => { @@ -434,7 +434,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) const log = logger(); const ql = makeQl({ sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], - grants: [{ id: 'ups_1', user_id: SystemUserId.SYSTEM, permission_set_id: 'ps_admin', organization_id: null }], + grants: [{ id: 'ups_1', user_id: SystemUserId.SYSTEM, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); expect(r.reason).toBe('walled_owner_email_undeclared'); @@ -540,7 +540,7 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA const ql = makeQl({ sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], users: [user('u_admin', 'admin@corp.example', '2026-08-23T01:00:00Z')], - grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: null }], + grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); expect(r.adminPromoted).toBe(false); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index d76b311f789..9f11e9450d2 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -104,6 +104,7 @@ import { readRecordedStandingSnapshot, serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; +import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -611,11 +612,35 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * runtime/app-plugin.ts `ensureSeedIdentity`) never counts — otherwise a DB * where it was wrongly promoted would block every real admin forever. * Ignoring it here makes the bootstrap self-healing on restart. + * + * ## By name, and the grant that names nothing yet ([ADR-0131] D4) + * + * Legs A and B ask for grants NAMING `admin_full_access` + * (`sys_user_permission_set.permission_set`), not for grants carrying the set + * row's id: a holder is a grant of the set by name. + * + * A grant written before that column existed carries no name until the + * one-time backfill names it — and the backfill runs at `kernel:bootstrapped`, + * AFTER the `kernel:ready` bootstrap that asks this question. On the first boot + * of an upgraded deployment, then, its platform administrator's grant names + * nothing yet, and a by-name scan alone would answer "nobody", promote the + * oldest user and hand them the seeded records: the second grant this function + * exists to refuse. So leg C, only when A and B found nobody, reaches the + * grants that name NOTHING through the set row's id, and an unscoped human one + * comes back as `unnamedHolder`. It only ever RESTRICTS: the bootstrap + * withholds the promotion on it, and it is never a holder — never an + * `adminUserId`, never a claim target ({@link findExistingPlatformAdmin}). */ async function findPlatformAdminGrantHolder( ql: any, adminPsId: string, -): Promise<{ holder: any | undefined; adminGrantRowsExamined: number; truncated: boolean }> { +): Promise<{ + holder: any | undefined; + /** Leg C: an unscoped human grant of the admin set row that names no set yet (restricts only). */ + unnamedHolder: any | undefined; + adminGrantRowsExamined: number; + truncated: boolean; +}> { const isUnscopedHumanHolder = (r: any) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM; @@ -637,37 +662,49 @@ async function findPlatformAdminGrantHolder( const unscopedGrantRows = await tryFind( ql, 'sys_user_permission_set', - { permission_set_id: adminPsId, organization_id: null }, + { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: null }, PLATFORM_ADMIN_GRANT_PAGE_SIZE, ADMIN_GRANT_SCAN_ORDER, ); countExamined(unscopedGrantRows); let holder: any | undefined = unscopedGrantRows.find(isUnscopedHumanHolder); - // Leg B — the ordered, bounded scan that still applies the exact predicate. - if (!holder) { + /** + * The ordered, bounded scan of one grant population, stopping at the first + * row `matches` accepts. + */ + const scan = async (where: Record, matches: (r: any) => boolean): Promise => { const pageSize = PLATFORM_ADMIN_GRANT_PAGE_SIZE; const ceiling = PLATFORM_ADMIN_GRANT_SCAN_CEILING; - for (let offset = 0; offset < ceiling && !holder; offset += pageSize) { + for (let offset = 0; offset < ceiling; offset += pageSize) { const pageLimit = Math.min(pageSize, ceiling - offset); - const page = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: adminPsId }, - pageLimit, - ADMIN_GRANT_SCAN_ORDER, - offset, - ); + const page = await tryFind(ql, 'sys_user_permission_set', where, pageLimit, ADMIN_GRANT_SCAN_ORDER, offset); if (page.length === 0) break; countExamined(page); - holder = page.find(isUnscopedHumanHolder); - if (holder) break; + const found = page.find(matches); + if (found) return found; if (page.length < pageLimit) break; if (offset + page.length >= ceiling) truncated = true; } + return undefined; + }; + + // Leg B — the ordered, bounded scan that still applies the exact predicate. + if (!holder) { + holder = await scan({ [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME }, isUnscopedHumanHolder); } - return { holder, adminGrantRowsExamined: examinedGrantRowIds.size, truncated }; + // Leg C — the grants that name nothing yet, through the set row's id; it + // restricts only (see the function note). + let unnamedHolder: any | undefined; + if (!holder) { + unnamedHolder = await scan( + { permission_set_id: adminPsId }, + (r) => grantSetNameOf(r) === undefined && isUnscopedHumanHolder(r), + ); + } + + return { holder, unnamedHolder, adminGrantRowsExamined: examinedGrantRowIds.size, truncated }; } /** @@ -688,7 +725,11 @@ async function findPlatformAdminGrantHolder( * - no {@link PLATFORM_ADMIN_PERMISSION_SET_NAME} among the sets this plugin * seeds (`admin_permission_set_missing`), or no stored row for it yet; * - nobody holds the unscoped grant yet — a first boot, where the promotion - * that follows does its own claim. + * that follows does its own claim; + * - [ADR-0131 D4] the only unscoped grant of the set names nothing yet + * (`unnamedHolder`): it restricts the promotion and confers nothing, so it + * names no claim target — the claim of a later boot, once the backfill has + * named the grant, hands the rows over. * * Read per call and never cached: the answer is final only once the grant * table stops moving, and a sign-up can promote someone later in this boot. @@ -928,6 +969,7 @@ export async function bootstrapPlatformAdmin( // bounded and ordered is on the function. const { holder: unscopedHolder, + unnamedHolder, adminGrantRowsExamined, truncated: adminGrantScanTruncated, } = await findPlatformAdminGrantHolder(ql, adminPsId); @@ -938,7 +980,7 @@ export async function bootstrapPlatformAdmin( // grant and hands it the seeded business records. So it says the number it // examined rather than letting the promotion below read as a statement about // the whole table. - if (adminGrantScanTruncated && !unscopedHolder) { + if (adminGrantScanTruncated && !unscopedHolder && !unnamedHolder) { const truncation = '[security] the existing-platform-admin check stopped at its ceiling of ' + `${PLATFORM_ADMIN_GRANT_SCAN_CEILING} admin_full_access grant row(s) ` @@ -975,6 +1017,31 @@ export async function bootstrapPlatformAdmin( }; } + // [ADR-0131 D4] An unscoped grant of the admin set row that names no set yet + // — on an upgraded deployment's first boot, its platform administrator's + // grant, which the name backfill reaches only at `kernel:bootstrapped`. It is + // read as an administrator who may already exist: promoting now would mint + // the second grant {@link findPlatformAdminGrantHolder} exists to refuse. It + // names no `adminUserId` — it restricts, it confers nothing — so the seed + // claim waits for a boot that reads the grant by its name. + if (!walled && unnamedHolder) { + const withheld = + '[security] platform-admin promotion WITHHELD: an unscoped admin_full_access grant exists whose ' + + 'permission-set name (sys_user_permission_set.permission_set) is not written yet, so this pass ' + + 'cannot read it as the holder and will not mint a second one. The one-time grant-name backfill ' + + 'names it at kernel:bootstrapped; the next pass reads it by name. If it stays unnamed, the ' + + 'backfill\'s own report says why.'; + if (logger?.warn) logger.warn(withheld); + else logger?.info?.(withheld); + return { + seeded: seededCount, + adminPromoted: false, + reason: 'admin_grant_unnamed', + ...resyncCounts, + ...grantScanCounts, + }; + } + if (walled) { // [#11974 / #11663 L4, Choice 5A first half] The walled promotion is // RETIRED: no `sys_user_permission_set` row is minted, whatever accounts diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index 34eb8837a14..afee98f3087 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -281,6 +281,7 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () id: 'ups_existing', user_id: ADMIN, permission_set_id: 'ps_admin_full_access', + permission_set: 'admin_full_access', organization_id: null, }); rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' }); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts index 468ff30d39e..78bfbbc202d 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts @@ -309,8 +309,8 @@ describe('the claim target is the existing platform admin, by the bootstrap rule await engine.insert('sys_user', { id, email: `${id}@example.test`, name: id, created_at: createdAt }, SYS); await engine.insert('sys_account', { id: `acc_${id}`, user_id: id, account_id: id, provider_id: 'credential' }, SYS); } - await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_zed', permission_set_id: 'ps_admin', organization_id: null }, SYS); - await engine.insert('sys_user_permission_set', { id: 'ups_2', user_id: 'usr_amy', permission_set_id: 'ps_admin', organization_id: null }, SYS); + await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_zed', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, SYS); + await engine.insert('sys_user_permission_set', { id: 'ups_2', user_id: 'usr_amy', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, SYS); return engine; } diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index b83e54f1dab..8cb7674ebe3 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -347,6 +347,52 @@ describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => context: h.ctxOf('delegate'), })).rejects.toThrow(/not strictly contained/); }); + + // [ADR-0131 D4] A stored grant's pre-image is judged by the set it NAMES + // (`permission_set`), read from the catalog by that name; an insert, or an + // update that re-points the id, is judged by the id it writes, as before. + describe('[ADR-0131 D4] the pre-image is judged by the name the grant holds', () => { + const storedGrant = (row: Record) => { + for (const set of h.tables.sys_permission_set) set.organization_id ??= null; + h.tables.sys_user_permission_set = [{ id: 'g1', user_id: 'u_east_1', ...row }]; + }; + const remove = () => h.gate.assert({ + object: 'sys_user_permission_set', operation: 'delete', + options: { where: { id: 'g1' } }, context: h.ctxOf('delegate'), + }); + + it('a grant naming an allowlisted set may be revoked by the delegate — whatever its id says', async () => { + storedGrant({ permission_set_id: 'ps_fin', permission_set: 'sales_user' }); + await expect(remove()).resolves.toBeUndefined(); + }); + + it('a grant naming a set outside the allowlist is refused — whatever its id says', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'finance_admin' }); + await expect(remove()).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); + }); + + it('a grant that names NO set is refused: the delegate cannot be shown to hold authority over it', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: null }); + await expect(remove()).rejects.toThrow(/names no permission set/); + }); + + it('a grant naming a set no catalog row carries is refused', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user_retired' }); + await expect(remove()).rejects.toThrow(/has no catalog row/); + }); + + it('an update keeping the set is judged by the stored name; one re-pointing the id by the new id', async () => { + storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user' }); + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'update', + data: { id: 'g1', reason: 'renewed' }, context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'update', + data: { id: 'g1', permission_set_id: 'ps_fin' }, context: h.ctxOf('delegate'), + })).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/); + }); + }); }); describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => { diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 8fb5c414ec3..b72171dc896 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -45,6 +45,7 @@ import { rowOrganizationId, seedCtx as organizationScopedCtx, } from './per-organization-catalog.js'; +import { GRANT_SET_ID_FIELD, grantSetNameOf, readGrantSetRows } from './grant-permission-set-name.js'; const SYSTEM_CTX = { isSystem: true } as const; /** @@ -81,9 +82,11 @@ const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000; * any other organization pairs an authority minted in one tenant with a tree * owned by another — which is what an unscoped by-name read did. * - * Undefined for a `single`-posture caller, and for any context carrying no - * organization at all: the organization-less surface that posture correctly - * has, where a by-name anchor is unambiguous and nothing here changes. + * Under the `single` posture this is the Default Organization: since ADR-0131 + * C1 it exists from boot, and every session on a stock `single` deployment — + * the administrator's and a delegate's alike — carries it as its active + * organization. Undefined only for a context carrying no organization at all, + * where a by-name anchor is read organization-less and nothing here changes. * * Under the `group` posture (ADR-0105 D2) this is the ACTIVE organization, not * the caller's whole membership set. That is the narrower of the two, which is @@ -101,7 +104,7 @@ function callerOrganizationId(context: any): string | undefined { * The runtime resolver's own answer (`resolveAuthzContext` step 4, in * `@objectstack/core`): a holding stamped with a DIFFERENT organization grants * nothing there; an organization-less holding grants in every organization; - * `organizationId` undefined (a `single`-posture caller) drops nothing. + * `organizationId` undefined (a context carrying no organization) drops nothing. * * Every holding the gate reasons about goes through this, because * `sys_user_position.position` is a position NAME and `sys_position.name` is @@ -825,13 +828,13 @@ export class DelegatedAdminGate { const targets = await this.materializeTargets(opCtx, 'sys_user_permission_set'); for (const t of targets) { const row = t.next ?? t.prev ?? {}; - const setRow = await this.loadSetRowById(row.permission_set_id); - const setName = String(setRow?.name ?? row.permission_set_id ?? ''); + const { setRow, setName, unresolved } = await this.directGrantSet(t, row); const targetUserId = row.user_id ? String(row.user_id) : null; const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set(); const failure = this.firstApprovalFailure(held, (s) => { if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; + if (unresolved) return unresolved; if (!s.scope.assignablePermissionSets.includes(setName)) { return `permission set '${setName}' is not in the scope's allowlist`; } @@ -1264,6 +1267,61 @@ export class DelegatedAdminGate { } } + /** + * [ADR-0131 D4] The permission set a direct-grant write is judged on. + * + * - **The grant's own set** — a delete, or an update that keeps the grant's + * set: read BY NAME from the grant's `permission_set` (the pre-image's, or + * the name the update supplies), through {@link readGrantSetRows}. A grant + * that names nothing, or names a set with no catalog row it applies to, + * is not one a delegate can be shown to hold authority over, so the write + * is refused (`unresolved`) — fail closed; a tenant admin is not judged + * here at all. + * - **The set the write points the grant AT** — an insert, or an update that + * re-points `permission_set_id`: the caller's own reference, read by that + * id as before. The name is not the caller's to supply (the platform + * derives it from that id after this gate), so there is no name to read. + */ + private async directGrantSet( + t: { next: any | null; prev: any | null }, + row: any, + ): Promise<{ setRow: any | null; setName: string; unresolved?: string }> { + const repointed = !!t.next + && (!t.prev || String(t.next[GRANT_SET_ID_FIELD] ?? '') !== String(t.prev[GRANT_SET_ID_FIELD] ?? '')); + if (repointed) { + const setRow = await this.loadSetRowById(row[GRANT_SET_ID_FIELD]); + return { setRow, setName: String(setRow?.name ?? row[GRANT_SET_ID_FIELD] ?? '') }; + } + const name = grantSetNameOf(row); + if (!name) { + return { + setRow: null, + setName: '', + unresolved: 'the grant names no permission set (it is not yet named, or could not be) — only a tenant admin may change it', + }; + } + const setRow = await this.loadSetRowForGrant(row); + if (!setRow) { + return { + setRow: null, + setName: name, + unresolved: `permission set '${name}' has no catalog row this grant applies to — only a tenant admin may change it`, + }; + } + return { setRow, setName: String(setRow.name ?? name) }; + } + + /** The catalog row a stored grant names, by its name (`null` when none applies or the read fails). */ + private async loadSetRowForGrant(grant: any): Promise { + if (!this.deps.ql?.find) return null; + try { + const setRowOf = await readGrantSetRows(this.deps.ql, [grant]); + return setRowOf(grant) ?? null; + } catch { + return null; + } + } + private async loadSetRowById(id: unknown): Promise { if (id == null || !this.deps.ql?.find) return null; try { diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index c1e438fece2..96b9ca5b55f 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -875,6 +875,9 @@ function makeGrantQl(tables: Rows) { return (tables[object] ?? []).filter((row) => Object.entries(where).every(([key, cond]) => { if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); const cell = row[key]; + // `null` matches a null-valued AND a key-absent cell — the memory + // driver's reading, and what an organization-less row looks like here. + if (cond === null) return cell === null || cell === undefined; if (cond && typeof cond === 'object' && '$in' in (cond as any)) { return ((cond as any).$in as unknown[]).includes(cell); } @@ -1001,8 +1004,11 @@ describe('buildContextForUser', () => { { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1' }, - { user_id: 'u2', permission_set_id: 'ps2', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { + user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + valid_until: '2026-06-01T00:00:00Z', + }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, @@ -1025,8 +1031,8 @@ describe('buildContextForUser', () => { it('reports a directly-granted permission set whose catalogue row is deactivated (ADR-0049 / #8714)', async () => { const qlDeactivated = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1' }, - { user_id: 'u2', permission_set_id: 'psOff' }, + { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, @@ -1065,7 +1071,7 @@ describe('buildContextForUser', () => { it('a row both expired AND deactivated reports expired — one reason per row, resolver drop order (#8714)', async () => { const qlBoth = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'psOff', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], }); @@ -1291,9 +1297,12 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => ], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps2', valid_until: '2026-06-01T00:00:00Z' }, + { + user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + valid_until: '2026-06-01T00:00:00Z', + }, // [commit 42b05af89] a held direct grant whose SET's catalogue row is switched off - { user_id: 'u2', permission_set_id: 'psOff' }, + { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps2', name: 'quarter_close_admin' }, diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 8384bb16609..7387f4ea385 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -60,6 +60,7 @@ import { unresolvedPostureExplainDetail, type UnresolvedPostureCause, } from './unresolved-posture.js'; +import { grantSetNameOf, readGrantSetRows } from './grant-permission-set-name.js'; const SYSTEM_CTX = { isSystem: true } as const; @@ -508,30 +509,28 @@ async function collectGrantProvenance( try { const rows = await ql.find('sys_user_permission_set', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX }); - const grantRows = Array.isArray(rows) ? rows : []; - const idOf = (g: any) => g?.permission_set_id ?? g?.permissionSetId; + // [ADR-0131 D4] A grant names its set (`permission_set`); one that names + // nothing ({@link grantSetNameOf}) reports nothing — it resolves no set. + const grantRows = (Array.isArray(rows) ? rows : []).filter((g: any) => grantSetNameOf(g) !== undefined); const expiredRows = grantRows.filter((g: any) => !isGrantActive(g, nowMs) && isGrantExpired(g, nowMs)); // Window-ACTIVE direct grants: resolvable unless the SET's catalogue row is // deactivated — the one remaining reason the resolver drops them (step 6b). const activeRows = grantRows.filter((g: any) => isGrantActive(g, nowMs)); - const ids = Array.from(new Set([...expiredRows, ...activeRows].map(idOf).filter(Boolean))); - if (ids.length > 0) { - // [commit 42b05af89] The existing by-id `sys_permission_set` read now serves both - // reasons: names for the expired rows, and the ADR-0049 `active` flag for - // the held ones — one read, no second query shape. - const sets = await ql.find('sys_permission_set', { where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX }); - const rowById = new Map(); - for (const s of Array.isArray(sets) ? sets : []) { - if ((s as any)?.id) rowById.set(String((s as any).id), s); - } + if (expiredRows.length > 0 || activeRows.length > 0) { + // [commit 42b05af89] One `sys_permission_set` read serves both reasons: + // the set row an expired grant names, and the ADR-0049 `active` flag of + // the one a held grant names. [ADR-0131 D4] Read by the grant's NAME — + // its own organization's row, else the organization-less one + // ({@link readGrantSetRows}); deactivation is still the row's flag. + const setRowOf = await readGrantSetRows(ql, [...expiredRows, ...activeRows]); for (const g of expiredRows) { - const s = rowById.get(String(idOf(g) ?? '')); + const s = setRowOf(g); const n = (s as any)?.name; if (n) droppedGrants.push({ kind: 'permission_set', name: String(n), state: 'expired', until: untilOfGrantRow(g) }); } const deactivatedNames = new Set(); for (const g of activeRows) { - const s = rowById.get(String(idOf(g) ?? '')); + const s = setRowOf(g); const n = (s as any)?.name; // Dedupe: several grant rows can point at one deactivated set; the // catalogue-row fact is reported once. diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.ts index 13d9ddcbaa9..4b5e99c22b3 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.ts @@ -11,8 +11,11 @@ * must say which set it holds by the set's machine name. The id column is * dropped later, after every grant names its set and the names are verified to * resolve (D10). Until then the grant carries both, and this module is what - * keeps them saying the same thing. Readers still read the id; rows written - * before the column existed are rewritten by the backfill stage, never here. + * keeps them saying the same thing. Rows written before the column existed are + * rewritten by the backfill stage, never here. The grant readers of + * `plugin-security` and `plugin-auth` key on the name ({@link grantSetNameOf}, + * {@link readGrantSetRows}); the authorization resolver in `@objectstack/core` + * still reads the id until its own stage switches it. * * ## The invariant: the two columns never disagree * @@ -194,6 +197,122 @@ function idKey(value: unknown): string | undefined { return key; } +/** + * [ADR-0131 D4] The permission set a STORED grant names, as every by-name + * grant reader asks it: the grant's `permission_set`, or `undefined` for a + * grant that names none — `NULL` or blank, which is a grant written before the + * column existed that the backfill has not named yet, or one it could not name + * (an id with no set row, or a set row of another organization). + * + * A grant that names nothing grants nothing through a by-name reader: it is + * never matched to a set, never shown as held, never counted. A reader whose + * job is to RESTRICT — a revocation, a refused promotion, a duplicate it must + * not insert — may still reach such a grant through its id until ADR-0131 C8 + * counts the unnamed grants and drops that column; no reader reaches it + * through the id to confer anything. + */ +export function grantSetNameOf(row: unknown): string | undefined { + if (!row || typeof row !== 'object') return undefined; + const value = (row as Record)[GRANT_SET_NAME_FIELD]; + if (typeof value !== 'string') return undefined; + const name = value.trim(); + return name === '' ? undefined : name; +} + +/** + * The organization a stored grant (or catalog row) belongs to, or `null` for + * one that belongs to none. A blank value is the legacy organization-less + * spelling, read the way every grant reader already reads it + * (`!organization_id`). + */ +export function grantOrganizationOf(row: unknown): string | null { + if (!row || typeof row !== 'object') return null; + const r = row as Record; + const value = r.organization_id ?? r.organizationId; + if (value === null || value === undefined || value === '') return null; + return String(value); +} + +/** The engine surface {@link readGrantSetRows} reads through. */ +export interface GrantSetRowEngine { + find(object: string, options: Record): Promise; +} + +/** + * [ADR-0131 D4] The `sys_permission_set` row each of `grants` reads its set + * from, BY NAME — for a reader that needs the row's own columns (the ADR-0049 + * `active` flag, a delegated-administration scope), not only the name. + * + * The catalog is still materialized per organization, so one name can carry + * several rows. A grant reads the row of its OWN organization, else the + * organization-less one — the same two rows its id may point at (a set row + * applies to a grant of its own organization, an organization-less row to + * every grant). An organization's rows are read with that organization + * threaded into the context, so the driver's tenant scope decides what is + * visible; an organization-less grant reads organization-less rows only. + * + * A grant that names nothing ({@link grantSetNameOf}) resolves to no row, and + * so does a name no applicable row carries. A read that fails throws: the + * caller decides what a failed read means for its own answer. + */ +export async function readGrantSetRows( + engine: GrantSetRowEngine, + grants: readonly unknown[], +): Promise<(grant: unknown) => Record | undefined> { + const namesByOrganization = new Map>(); + const allNames = new Set(); + for (const grant of grants) { + const name = grantSetNameOf(grant); + if (!name) continue; + allNames.add(name); + const organizationId = grantOrganizationOf(grant); + if (organizationId === null) continue; + let names = namesByOrganization.get(organizationId); + if (!names) { + names = new Set(); + namesByOrganization.set(organizationId, names); + } + names.add(name); + } + const rowsOf = (result: unknown): Array & { name: string }> => + (Array.isArray(result) ? result : []).filter( + (r): r is Record & { name: string } => + !!r && typeof r === 'object' && typeof (r as Record).name === 'string', + ); + const organizationLess = new Map>(); + if (allNames.size > 0) { + const names = [...allNames]; + const rows = rowsOf(await engine.find(PERMISSION_SET_CATALOG_OBJECT, { + where: { name: { $in: names }, organization_id: null }, + limit: names.length * 2 + 1, + context: { isSystem: true }, + })); + for (const row of rows) { + if (grantOrganizationOf(row) === null && !organizationLess.has(row.name)) organizationLess.set(row.name, row); + } + } + const own = new Map>>(); + for (const [organizationId, nameSet] of namesByOrganization) { + const names = [...nameSet]; + const rows = rowsOf(await engine.find(PERMISSION_SET_CATALOG_OBJECT, { + where: { name: { $in: names } }, + limit: names.length * 2 + 1, + context: { isSystem: true, tenantId: organizationId }, + })); + const byName = new Map>(); + for (const row of rows) { + if (grantOrganizationOf(row) === organizationId && !byName.has(row.name)) byName.set(row.name, row); + } + own.set(organizationId, byName); + } + return (grant: unknown) => { + const name = grantSetNameOf(grant); + if (!name) return undefined; + const organizationId = grantOrganizationOf(grant); + return (organizationId !== null ? own.get(organizationId)?.get(name) : undefined) ?? organizationLess.get(name); + }; +} + /** Per-write memo, keyed by the dispatch scope every hook dispatch of one caller write shares. */ const memoByWrite = new WeakMap>(); diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts new file mode 100644 index 00000000000..65a0e71bd73 --- /dev/null +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -0,0 +1,471 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] Grant-equivalence goldens for the `plugin-security` grant + * readers that key on `sys_user_permission_set.permission_set`, the grant's + * permission set BY NAME, instead of `permission_set_id`. + * + * The readers change their key and not their answer. This suite boots a real + * ObjectQL engine over the SQL driver with the real `SecurityPlugin` (so every + * grant is written through the engine hooks that keep the two columns in step) + * and records, per principal, what each reader in this package answers: + * + * - **the explain engine** (`buildContextForUser`): positions, permissions, + * system permissions, tab permissions, posture and the dropped-grant + * provenance — the expired and deactivated grants are the part read through + * the grant's name; + * - **platform standing** as the bootstrap reads it: who already holds the + * unscoped `admin_full_access` grant (`findExistingPlatformAdmin`, and the + * `already_have_admin` answer of a second `bootstrapPlatformAdmin` pass); + * - **organization-administrator standing** as the reconcile reads it: a + * re-run per principal, the boot backfill, and a demotion. + * + * Principals: the platform administrator (`single`: the promoted first user, a + * grant row; walled: the declared `OS_PLATFORM_OWNER_EMAIL` owner, no row), the + * organization administrator (the owner membership, reconciled), a member (a + * set granted through the data door, plus a grant of a DEACTIVATED set) and an + * agent (a windowed agent grant, plus an EXPIRED one). Three postures: + * `single`, `group` and `isolated`. + * + * The golden below was recorded on the tree BEFORE the readers moved to the + * name (the PR record carries that run) and is unchanged after it. Pointing one + * grant's name column at a different set turns it red — the PR record carries + * that ablation too. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; +import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +import { buildContextForUser } from './explain-engine.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_gr'; +const EXPIRED = '2020-01-01T00:00:00.000Z'; +const FUTURE = '2099-01-01T00:00:00.000Z'; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +type Posture = 'single' | 'group' | 'isolated'; + +const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); + } + return value; +}; + +/** What the explain engine answers for one principal, minus the row ids it does not decide. */ +function explainView(ctx: any): Record { + return { + positions: ctx.positions, + permissions: ctx.permissions, + systemPermissions: ctx.systemPermissions, + tabPermissions: ctx.tabPermissions ?? null, + posture: ctx.posture ?? null, + hasPlatformAdminGrant: ctx.hasPlatformAdminGrant, + droppedGrants: ctx.droppedGrants, + delegatedPositions: ctx.delegatedPositions, + }; +} + +async function readersByPrincipal(posture: Posture): Promise> { + const walled = posture !== 'single'; + if (walled) { + process.env[POSTURE_ENV] = posture; + process.env[OWNER_ENV] = 'admin@gr.example'; + } + resetPlatformAdminEmailMemo(); + + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.grant-readers-by-name', + name: 'Grant readers by name', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets, QA_ADMIN], + }, + ...(walled + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } + : {}), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const orgCtx = { isSystem: true, tenantId: ORG }; + await engine.insert('sys_organization', { id: ORG, name: 'GR Org', slug: 'gr' }, { context: SYS } as any); + const user = async (id: string, email: string, createdAt: string) => { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); + }; + await user('usr_admin', 'admin@gr.example', '2025-01-01T00:00:00.000Z'); + await user('usr_orgadmin', 'orgadmin@gr.example', '2025-02-01T00:00:00.000Z'); + await user('usr_member', 'member@gr.example', '2025-03-01T00:00:00.000Z'); + await user('usr_agent', 'agent@gr.example', '2025-04-01T00:00:00.000Z'); + + // The catalog and (single) the platform-admin promotion. + const boot = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + expect(boot.adminPromoted).toBe(posture === 'single'); + + // Walled: the organization's own copies of the sets its grants point at. + if (walled) { + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; + await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); + } + } + // A deactivated set the member still holds a grant of (ADR-0049). + await engine.insert( + 'sys_permission_set', + { + id: 'ps_legacy_reports', + name: 'legacy_reports', + label: 'Legacy reports', + active: false, + ...(walled ? { organization_id: ORG } : {}), + }, + { context: walled ? orgCtx : SYS } as any, + ); + + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + { id: 'm_agent', user_id: 'usr_agent', organization_id: ORG, role: 'member', created_at: '2025-04-01T00:00:00.000Z' }, + ], { context: orgCtx } as any); + const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); + expect(reconciled.action).toBe('granted'); + + // The member's set, granted through the data door by an administrator. + const [viewer] = await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', ...(walled ? { organization_id: ORG } : {}) }, + context: SYS, + }); + await engine.insert( + 'sys_user_permission_set', + { user_id: 'usr_member', permission_set_id: (viewer as any).id }, + { + context: { + userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG], + }, + } as any, + ); + // System-written grants: the member's deactivated set, the agent's windowed + // and expired agent grants (the platform bucket's organization-less rows). + const setId = async (name: string) => + String(((await engine.find('sys_permission_set', { where: { name, organization_id: null }, context: SYS })) as any[])[0]?.id); + await engine.insert('sys_user_permission_set', [ + { id: 'ups_member_legacy', user_id: 'usr_member', permission_set_id: 'ps_legacy_reports', organization_id: ORG }, + { + id: 'ups_agent_read', user_id: 'usr_agent', permission_set_id: await setId('mcp_agent_data_read'), + organization_id: ORG, valid_until: FUTURE, + }, + { + id: 'ups_agent_write', user_id: 'usr_agent', permission_set_id: await setId('mcp_agent_data_write'), + organization_id: ORG, valid_until: EXPIRED, + }, + ], { context: orgCtx } as any); + + const principals = { + platformAdmin: 'usr_admin', + organizationAdmin: 'usr_orgadmin', + member: 'usr_member', + agent: 'usr_agent', + } as const; + const byPrincipal: Record = {}; + for (const [kind, userId] of Object.entries(principals)) { + byPrincipal[kind] = { + explain: explainView(await buildContextForUser(engine, userId, Date.now(), ORG)), + reconcile: await reconcileOrgAdminGrant(engine, userId, ORG, { posture }), + }; + } + + const rerun = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + const standing = { + existingPlatformAdmin: (await findExistingPlatformAdmin(engine, defaultPermissionSets)) ?? null, + bootstrapRerun: { + adminPromoted: rerun.adminPromoted, + reason: rerun.reason ?? null, + adminUserId: rerun.adminUserId ?? null, + }, + }; + const backfill = await backfillOrgAdminGrants(engine, { posture }); + + // A demotion: the owner membership drops to member, and the reconcile + // revokes the organization-administrator grant. + await engine.update('sys_member', { id: 'm_owner', role: 'member' }, { context: orgCtx } as any); + const demotion = { + reconcile: await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }), + explain: explainView(await buildContextForUser(engine, 'usr_orgadmin', Date.now(), ORG)), + }; + + return sortDeep({ standing, principals: byPrincipal, backfill, demotion }) as Record; +} + +describe('[ADR-0131 D4] grant readers by name — every principal answers the recorded golden', () => { + for (const posture of ['single', 'group', 'isolated'] as const) { + it(`${posture}: explain, platform standing and organization-administrator standing per principal`, async () => { + const actual = await readersByPrincipal(posture); + expect(actual).toEqual(GOLDEN[posture]); + }); + } +}); + +/** + * Recorded on the tree before the readers moved to the name; `group` and + * `isolated` were recorded identical, so they share one literal. `single` and the two walled postures differ in exactly two places: the organization + * administrator's set (the walled `organization_admin` against the wall-less + * `organization_admin_no_bypass`, ADR-0105 D4) and platform standing (a grant row + * under `single`; the declared owner, which no grant row carries, under a wall). + */ +const SINGLE: unknown = { + backfill: { granted: 0, revoked: 0, scanned: 3, skipped: 0 }, + demotion: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: false, + permissions: [], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + principals: { + agent: { + explain: { + delegatedPositions: [], + droppedGrants: [ + { + kind: 'permission_set', + name: 'mcp_agent_data_write', + state: 'expired', + until: '2020-01-01T00:00:00.000Z', + }, + ], + hasPlatformAdminGrant: false, + permissions: ['mcp_agent_data_read'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + member: { + explain: { + delegatedPositions: [], + droppedGrants: [{ kind: 'permission_set', name: 'legacy_reports', state: 'deactivated' }], + hasPlatformAdminGrant: false, + permissions: ['viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + organizationAdmin: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: false, + permissions: ['organization_admin_no_bypass'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + platformAdmin: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: true, + permissions: ['admin_full_access'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log', + ], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + }, + standing: { + bootstrapRerun: { adminPromoted: false, adminUserId: 'usr_admin', reason: 'already_have_admin' }, + existingPlatformAdmin: 'usr_admin', + }, +}; + +const WALLED: unknown = { + backfill: { granted: 0, revoked: 0, scanned: 3, skipped: 0 }, + demotion: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: false, + permissions: [], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + principals: { + agent: { + explain: { + delegatedPositions: [], + droppedGrants: [ + { + kind: 'permission_set', + name: 'mcp_agent_data_write', + state: 'expired', + until: '2020-01-01T00:00:00.000Z', + }, + ], + hasPlatformAdminGrant: false, + permissions: ['mcp_agent_data_read'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + member: { + explain: { + delegatedPositions: [], + droppedGrants: [{ kind: 'permission_set', name: 'legacy_reports', state: 'deactivated' }], + hasPlatformAdminGrant: false, + permissions: ['viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + organizationAdmin: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: false, + permissions: ['organization_admin'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + platformAdmin: { + explain: { + delegatedPositions: [], + droppedGrants: [], + hasPlatformAdminGrant: true, + permissions: ['admin_full_access'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log', + ], + tabPermissions: null, + }, + reconcile: { action: 'noop' }, + }, + }, + standing: { + bootstrapRerun: { adminPromoted: false, adminUserId: null, reason: 'walled_config_derived' }, + existingPlatformAdmin: null, + }, +}; + +const GOLDEN: Record = { single: SINGLE, group: WALLED, isolated: WALLED }; diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts new file mode 100644 index 00000000000..8e708b30d7e --- /dev/null +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D4] The grant that names nothing, as each `plugin-security` grant + * reader treats it once the readers key on `sys_user_permission_set.permission_set`. + * + * Such a grant is real state: a grant written before the name column existed + * carries `NULL` until the one-time backfill names it, and the backfill runs at + * `kernel:bootstrapped` — after the `kernel:ready` passes (the platform-admin + * bootstrap, the organization-admin backfill) that read grants. On an upgraded + * deployment's first boot every grant is unnamed when those passes run. The + * backfill also leaves a grant unnamed for good when its id names no set row, + * or a set row of another organization. + * + * Each reader takes its fail-closed direction: + * + * - **explain** reports nothing about it — it names no set; + * - **the platform-admin bootstrap** reads an unscoped one on the admin set row + * as an administrator who may already exist, and withholds the promotion — + * without naming it as the holder; + * - **the organization-administrator reconcile** still reaches it through its + * id to revoke it, and never inserts a duplicate beside it. + * + * Real ObjectQL over the SQL driver with the real `SecurityPlugin`; a grant is + * unnamed by writing `NULL` with the name hooks unbound, the state the backfill + * leaves, then the hooks are bound again. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; +import { backfillOrgAdminGrants, reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +import { buildContextForUser } from './explain-engine.js'; +import { + GRANT_SET_NAME_HOOK_PACKAGE, + registerGrantPermissionSetNameHooks, +} from './grant-permission-set-name.js'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { isSystem: true } as const; +const ORG = 'org_un'; +const orgCtx = { isSystem: true, tenantId: ORG }; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(): Promise { + resetPlatformAdminEmailMemo(); + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.grant-readers-unnamed', + name: 'Grant readers — unnamed grant', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysUser, SysAccount, SysMember, SysOrganization, + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets], + }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + await engine.insert('sys_organization', { id: ORG, name: 'Unnamed Org', slug: 'un' }, { context: SYS } as any); + return engine; +} + +async function user(engine: ObjectQL, id: string, createdAt: string): Promise { + await engine.insert( + 'sys_user', { id, email: `${id}@un.example`, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any, + ); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, + { context: SYS } as any, + ); +} + +const setIdOf = async (engine: ObjectQL, name: string): Promise => + String(((await engine.find('sys_permission_set', { where: { name, organization_id: null }, context: SYS })) as any[])[0]?.id); + +/** The state the backfill leaves on a grant it has not named: `NULL`, written with the name hooks unbound. */ +async function unname(engine: ObjectQL, where: Record): Promise { + (engine as any).unregisterHooksByPackage(GRANT_SET_NAME_HOOK_PACKAGE); + await engine.update('sys_user_permission_set', { permission_set: null }, { where, multi: true, context: SYS } as any); + registerGrantPermissionSetNameHooks(engine as any); + const rows = (await engine.find('sys_user_permission_set', { where, context: SYS })) as any[]; + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.permission_set == null)).toBe(true); +} + +const grantsOf = async (engine: ObjectQL, userId: string): Promise => + (await engine.find('sys_user_permission_set', { where: { user_id: userId }, context: SYS })) as any[]; + +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(); + 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', active: false }, { context: SYS } as any); + await engine.insert('sys_user_permission_set', [ + { id: 'g_off', user_id: 'usr_member', permission_set_id: 'ps_off', organization_id: ORG }, + { + id: 'g_expired', user_id: 'usr_member', permission_set_id: await setIdOf(engine, 'viewer_readonly'), + organization_id: ORG, valid_until: '2020-01-01T00:00:00.000Z', + }, + ], { context: orgCtx } as any); + + const named = await buildContextForUser(engine, 'usr_member', Date.now(), ORG); + expect(named.droppedGrants.map((g: any) => `${g.state}:${g.name}`).sort()) + .toEqual(['deactivated:legacy_reports', 'expired:viewer_readonly']); + + await unname(engine, { user_id: 'usr_member' }); + const unnamed = await buildContextForUser(engine, 'usr_member', Date.now(), ORG); + expect(unnamed.droppedGrants).toEqual([]); + }); +}); + +describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootstrap (single)', () => { + /** + * The upgraded deployment's first boot: an administrator stands, through a + * grant the backfill has not named yet, and an OLDER account exists that the + * age rule would otherwise promote. + */ + async function standingAdminUnnamed(): Promise { + const engine = await boot(); + 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'); + await engine.insert( + 'sys_user_permission_set', + { id: 'g_admin', user_id: 'usr_admin', permission_set_id: await setIdOf(engine, 'admin_full_access') }, + { context: SYS } as any, + ); + expect((await grantsOf(engine, 'usr_admin'))[0]?.permission_set).toBe('admin_full_access'); + return engine; + } + + it('control — named, the grant is the holder: no promotion, and it names the claim target', async () => { + const engine = await standingAdminUnnamed(); + 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'); + }); + + 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' }); + const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('admin_grant_unnamed'); + expect(report.adminUserId).toBeUndefined(); + expect(await grantsOf(engine, 'usr_oldest')).toEqual([]); + }); + + it('unnamed, it names no claim target — it restricts, it confers nothing', async () => { + const engine = await standingAdminUnnamed(); + await unname(engine, { id: 'g_admin' }); + expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBeUndefined(); + }); +}); + +describe('[ADR-0131 D4] a grant that names nothing — the organization-administrator reconcile', () => { + async function orgAdmin(): Promise { + const engine = await boot(); + await bootstrapPlatformAdmin(engine, defaultPermissionSets); + await user(engine, 'usr_orgadmin', '2025-02-01T00:00:00.000Z'); + await engine.insert( + 'sys_member', + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { context: orgCtx } as any, + ); + // The owner membership's write reconciles on its own; this call makes sure. + await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG); + const granted = await grantsOf(engine, 'usr_orgadmin'); + expect(granted.map((g) => g.permission_set)).toEqual(['organization_admin_no_bypass']); + await unname(engine, { user_id: 'usr_orgadmin' }); + return engine; + } + + it('a qualifying pair holding it is not handed a duplicate grant', async () => { + const engine = await orgAdmin(); + expect(await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG)).toEqual({ action: 'noop' }); + expect(await grantsOf(engine, 'usr_orgadmin')).toHaveLength(1); + }); + + it('a demotion still revokes it — reached through its id', async () => { + const engine = await orgAdmin(); + await engine.update('sys_member', { id: 'm_owner', role: 'member' }, { context: orgCtx } as any); + await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG); + expect(await grantsOf(engine, 'usr_orgadmin')).toEqual([]); + }); + + it('the boot backfill still revokes it once its pair has no membership left', async () => { + const engine = await orgAdmin(); + await user(engine, 'usr_orphan', '2025-05-01T00:00:00.000Z'); + await engine.insert( + 'sys_user_permission_set', + { + id: 'g_orphan', user_id: 'usr_orphan', organization_id: ORG, + permission_set_id: await setIdOf(engine, 'organization_admin_no_bypass'), + }, + { context: orgCtx } as any, + ); + await unname(engine, { id: 'g_orphan' }); + const summary = await backfillOrgAdminGrants(engine); + expect(summary.revoked).toBe(1); + expect(await grantsOf(engine, 'usr_orphan')).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 2bed0a5ade9..be519c6a2c7 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -83,8 +83,10 @@ export const SysUserPermissionSet = ObjectSchema.create({ // VALIDATION_FAILED, `invalid_value` here). Same width and shape as the // sibling assignment's `sys_user_position.position` (a `sys_*.name`, 100). // Not required: a grant written before this column existed carries NULL - // until the backfill stage rewrites it, and no reader consults the column - // yet — readers keep reading the id until they are switched. + // until the backfill stage rewrites it. The grant readers of + // plugin-security and plugin-auth read this column, and a grant with no + // name grants nothing through them (`grantSetNameOf`); the resolver in + // @objectstack/core still reads the id until its own stage switches it. permission_set: Field.text({ label: 'Permission Set Name', required: false, diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index e105dceffc1..3c5115949e8 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -404,6 +404,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistLedgerDecisionRow', "A one-time membership decision (the ADR-0093 D6 backfill, or the default organization's owner bind) was carried out but its `sys_migration` record was never written. Every log line reads clean and the memberships it wrote stand, while the record that makes the decision ONE-TIME is absent, so the next boot decides it again — re-deciding membership for users whose membership was already decided at creation (ADR-0093 D7).", ], + [ + 'persistGrantNameBackfillRecord', + "The one-time grant-name backfill (ADR-0131 D4) named the grants it could, but its `sys_migration` verdict row was never written. Every log line reads clean and the names it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole grant table again and re-reports the grants it cannot name.", + ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 1f5656b05ff..2d0ebcacf5b 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -329,6 +329,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistPackageCommitRow', 'gate-vocabulary'], ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], ['persistLedgerDecisionRow', 'gate-vocabulary'], + ['persistGrantNameBackfillRecord', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'],