|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0131 D5] Under `single` the first-user promotion's `admin_full_access` |
| 5 | + * grant is OWNED BY THE DEFAULT ORGANIZATION, and `resolveAuthzContext` reads |
| 6 | + * that row as the anchor of platform standing (`grantAnchorsPlatformStanding`). |
| 7 | + * The break-glass guard answers the same question, so two things hold here: |
| 8 | + * |
| 9 | + * 1. **The enumeration counts that grant's holder.** An enumeration reading |
| 10 | + * only the organization-less row would see an environment with no |
| 11 | + * administrator and wave through the ban, the revocation or the re-scoping |
| 12 | + * that ends the last one. |
| 13 | + * 2. **The guard judges the organization writes that move the anchor.** The |
| 14 | + * Default Organization is the one with `slug = 'default'`, else the only |
| 15 | + * one — so re-slugging it while another organization exists, or deleting |
| 16 | + * it, takes the standing away with no grant row touched. |
| 17 | + * |
| 18 | + * Same method as `last-admin-guard.config-anchor.test.ts`: a REAL ObjectQL |
| 19 | + * engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 23 | +import { ObjectQL } from '@objectstack/objectql'; |
| 24 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 25 | +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; |
| 26 | +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; |
| 27 | + |
| 28 | +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; |
| 29 | +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; |
| 30 | + |
| 31 | +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; |
| 32 | +const MULTI_ORG_ENV = 'OS_MULTI_ORG_ENABLED'; |
| 33 | +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; |
| 34 | +const SYSTEM = { context: { isSystem: true } } as const; |
| 35 | + |
| 36 | +const text = (name: string) => ({ name, type: 'text' as const }); |
| 37 | +const OBJECTS = [ |
| 38 | + { |
| 39 | + name: 'sys_user', |
| 40 | + label: 'User', |
| 41 | + managedBy: 'better-auth', |
| 42 | + fields: { |
| 43 | + id: { ...text('id'), primaryKey: true }, |
| 44 | + name: text('name'), |
| 45 | + email: text('email'), |
| 46 | + email_verified: { name: 'email_verified', type: 'boolean' as const }, |
| 47 | + banned: { name: 'banned', type: 'boolean' as const, readonly: true }, |
| 48 | + }, |
| 49 | + }, |
| 50 | + { |
| 51 | + name: 'sys_member', |
| 52 | + label: 'Member', |
| 53 | + managedBy: 'better-auth', |
| 54 | + fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), organization_id: text('organization_id'), role: text('role') }, |
| 55 | + }, |
| 56 | + { |
| 57 | + name: 'sys_organization', |
| 58 | + label: 'Organization', |
| 59 | + managedBy: 'better-auth', |
| 60 | + fields: { id: { ...text('id'), primaryKey: true }, name: text('name'), slug: text('slug') }, |
| 61 | + }, |
| 62 | + { |
| 63 | + name: 'sys_user_permission_set', |
| 64 | + label: 'User Permission Set', |
| 65 | + fields: { |
| 66 | + id: { ...text('id'), primaryKey: true }, |
| 67 | + user_id: text('user_id'), |
| 68 | + permission_set: text('permission_set'), |
| 69 | + organization_id: text('organization_id'), |
| 70 | + valid_from: { name: 'valid_from', type: 'datetime' as const }, |
| 71 | + valid_until: { name: 'valid_until', type: 'datetime' as const }, |
| 72 | + }, |
| 73 | + }, |
| 74 | +]; |
| 75 | + |
| 76 | +let engines: ObjectQL[] = []; |
| 77 | +const ambient: Record<string, string | undefined> = {}; |
| 78 | + |
| 79 | +beforeEach(() => { |
| 80 | + for (const name of [POSTURE_ENV, MULTI_ORG_ENV, OWNER_ENV]) { |
| 81 | + ambient[name] = process.env[name]; |
| 82 | + delete process.env[name]; |
| 83 | + } |
| 84 | + resetPlatformAdminEmailMemo(); |
| 85 | +}); |
| 86 | + |
| 87 | +afterEach(async () => { |
| 88 | + for (const [name, value] of Object.entries(ambient)) { |
| 89 | + if (value === undefined) delete process.env[name]; |
| 90 | + else process.env[name] = value; |
| 91 | + } |
| 92 | + resetPlatformAdminEmailMemo(); |
| 93 | + const open = engines; |
| 94 | + engines = []; |
| 95 | + for (const e of open) { |
| 96 | + try { await e.destroy(); } catch { /* noop */ } |
| 97 | + } |
| 98 | +}); |
| 99 | + |
| 100 | +/** |
| 101 | + * A `single` environment whose sole administrator holds the grant the promotion |
| 102 | + * writes — owned by the Default Organization — and nothing else (no |
| 103 | + * membership, so the organization-owner half of the enumeration counts nobody). |
| 104 | + */ |
| 105 | +async function promotedSingle(opts: { secondOrganization?: boolean; grantOwner?: string } = {}): Promise<ObjectQL> { |
| 106 | + const engine = new ObjectQL(); |
| 107 | + engines.push(engine); |
| 108 | + engine.registerDriver( |
| 109 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), |
| 110 | + true, |
| 111 | + ); |
| 112 | + await engine.init(); |
| 113 | + for (const o of OBJECTS) engine.registry.registerObject(o as never); |
| 114 | + await engine.syncSchemas(); |
| 115 | + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); |
| 116 | + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard.default-org' }); |
| 117 | + |
| 118 | + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM); |
| 119 | + if (opts.secondOrganization) { |
| 120 | + await engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM); |
| 121 | + } |
| 122 | + for (const id of ['usr_admin', 'usr_other']) { |
| 123 | + await engine.insert('sys_user', { id, name: id, email: `${id}@d.example`, email_verified: true, banned: false }, SYSTEM); |
| 124 | + } |
| 125 | + await engine.insert('sys_user_permission_set', { |
| 126 | + id: 'ups_admin', user_id: 'usr_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: opts.grantOwner ?? 'org_default', |
| 127 | + }, SYSTEM); |
| 128 | + return engine; |
| 129 | +} |
| 130 | + |
| 131 | +const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYSTEM); |
| 132 | +const refusal = { code: 'PERMISSION_DENIED', status: 403 }; |
| 133 | + |
| 134 | +describe('[ADR-0131 D5] the enumeration counts the holder of the Default Organization\'s grant', () => { |
| 135 | + it('banning that sole administrator is refused', async () => { |
| 136 | + const engine = await promotedSingle(); |
| 137 | + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); |
| 138 | + }); |
| 139 | + |
| 140 | + it('revoking the grant, or re-scoping it to another organization, is refused', async () => { |
| 141 | + const engine = await promotedSingle({ secondOrganization: true }); |
| 142 | + await expect(engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM } as any)) |
| 143 | + .rejects.toMatchObject(refusal); |
| 144 | + await expect(engine.update('sys_user_permission_set', { id: 'ups_admin', organization_id: 'org_b' }, SYSTEM)) |
| 145 | + .rejects.toMatchObject(refusal); |
| 146 | + }); |
| 147 | + |
| 148 | + it('CONTROL — another organization\'s grant of the set makes no administrator: banning its holder is permitted', async () => { |
| 149 | + const engine = await promotedSingle({ secondOrganization: true, grantOwner: 'org_b' }); |
| 150 | + await expect(ban(engine, 'usr_admin')).resolves.toBeDefined(); |
| 151 | + }); |
| 152 | +}); |
| 153 | + |
| 154 | +describe('[ADR-0131 D5] the organization writes that move the anchor off that grant are judged', () => { |
| 155 | + it('re-slugging the Default Organization while another organization exists is refused', async () => { |
| 156 | + const engine = await promotedSingle({ secondOrganization: true }); |
| 157 | + await expect(engine.update('sys_organization', { id: 'org_default', slug: 'renamed' }, SYSTEM)) |
| 158 | + .rejects.toMatchObject(refusal); |
| 159 | + }); |
| 160 | + |
| 161 | + it('deleting the Default Organization is refused', async () => { |
| 162 | + const engine = await promotedSingle(); |
| 163 | + await expect(engine.delete('sys_organization', { where: { id: 'org_default' }, ...SYSTEM } as any)) |
| 164 | + .rejects.toMatchObject(refusal); |
| 165 | + }); |
| 166 | + |
| 167 | + it('CONTROL — a sole organization stays the Default Organization whatever its slug, and a name change is no standing write', async () => { |
| 168 | + const engine = await promotedSingle(); |
| 169 | + await expect(engine.update('sys_organization', { id: 'org_default', slug: 'acme' }, SYSTEM)).resolves.toBeDefined(); |
| 170 | + await expect(engine.update('sys_organization', { id: 'org_default', name: 'Acme Inc' }, SYSTEM)).resolves.toBeDefined(); |
| 171 | + // …and the administrator is still counted afterwards. |
| 172 | + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); |
| 173 | + }); |
| 174 | +}); |
0 commit comments