Skip to content
Merged
22 changes: 22 additions & 0 deletions .changeset/24883-last-admin-guard-default-organization-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows this reads differently exist only on a database the ownership ceremony has not certified, or in a composition that registers no organization object. -->

**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way.

- **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before.
- The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded.
- It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator.
- Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there).
- **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before.

**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified.

**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization.
19 changes: 14 additions & 5 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ implementation detail:
deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — is sufficient under every posture. An
**unscoped `admin_full_access` grant** row is sufficient only under
`single`; under the walled postures (`group` / `isolated`) it is no longer
an anchor — the set it names still grants what any held set grants, but it
**`admin_full_access` grant owned by the Default Organization** is
sufficient only under `single`; a grant owned by no organization anchors
nothing in any posture, and under the walled postures (`group` /
`isolated`) no grant is an anchor — the set it names still grants what any held set grants, but it
confers no `PLATFORM_ADMIN` standing, so there standing comes from
configuration alone. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
Expand Down Expand Up @@ -361,7 +362,7 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
`admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.
Expand Down Expand Up @@ -450,7 +451,7 @@ high-privilege gate runs on the definition each time it is derived. See

- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
deactivated `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
Expand Down Expand Up @@ -480,6 +481,14 @@ grant away; a permission set or position with no ledger row is in effect. The
last-administrator guard reads the same two places the resolver does: whether
the catalogue holds `admin_full_access`, and whether the ledger switches it
off. It reads no catalogue row, so a row's `active` changes nothing it counts.
It counts the same anchors the resolver derives `platform_admin` from — under
`single`, the Default Organization's `admin_full_access` grant; under every
posture, the configured administrator list — beside organization owners and
admins. A grant owned by no organization counts nowhere. So a host that
registers the guard (`registerLastAdminGuard` from `@objectstack/plugin-auth`)
on an engine with no organization object has no grant-anchored administrator
at all: its administrators are the accounts declared in
`OS_PLATFORM_OWNER_EMAIL` and the organization owners and admins it holds.

**Switching the break-glass set off is refused while it would leave no
administrator.** A ledger row switching `admin_full_access` off un-makes every
Expand Down
20 changes: 11 additions & 9 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,17 @@ sufficient depends on the deployment's
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
This anchor is read under every posture.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It confers standing
under the `single` posture only. Under the walled postures (`group` /
`isolated`) the row is no longer an anchor: the set it names still grants
what any held set grants, but it confers no `PLATFORM_ADMIN` standing, and
nothing is logged about it per request. If a walled deployment holding such a
row ends up with no usable configured administrator, the boot log says it has
zero platform administrators, names the account that holds the row, and points
at `OS_PLATFORM_OWNER_EMAIL` as the way to restore that account's standing.
- **The Default Organization's `admin_full_access` grant row** — a
`sys_user_permission_set` row owned by the Default Organization, the row the
`single` first-user promotion writes. It confers standing under the `single`
posture only. An organization-less grant (`organization_id = NULL`, the row an
earlier release's promotion wrote, which the ownership ceremony attributes to
an organization) anchors nothing, in any posture. Under the walled postures
(`group` / `isolated`) no grant row is an anchor: the set it names still
grants what any held set grants, but it confers no `PLATFORM_ADMIN` standing,
and nothing is logged about it per request. A walled deployment that declares
no usable configured administrator is told so at boot: the log says it has
zero platform administrators and points at `OS_PLATFORM_OWNER_EMAIL`.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
* - the engine's memo of the install's organizations, which only an engine
* write to `sys_organization` invalidates: the rows are deleted below the
* engine, so a database that holds an organization row is refused rather
* than emptied;
* than emptied — unless the pool KEEPS the rows its boot wrote
* (`keepBootOrganizations`): they are then left in place, and a case that
* changed them is refused, so the memo still describes the table;
* - the schema itself: a case that created or dropped a table leaves an
* engine a fresh boot would not give, and the next `take()` refuses it;
* - the write epoch and the log-once sets: counters and logging only, read by
Expand Down Expand Up @@ -54,6 +56,12 @@ export interface BootedSqliteEngine<E extends ObjectQL = ObjectQL> {
export interface SqliteEnginePoolOptions<E extends ObjectQL> {
/** Per-engine fixture state the suite keeps beside the database (a catalog set, say), reset with it. */
onReset?: (engine: E) => void | Promise<void>;
/**
* Keep the `sys_organization` rows the boot wrote (a Default Organization, say)
* across resets instead of refusing them. A case that adds, changes or removes
* one is refused at the next `take()`, as a schema change is.
*/
keepBootOrganizations?: boolean;
}

export interface SqliteEnginePool<E extends ObjectQL> {
Expand All @@ -72,6 +80,8 @@ interface Entry<E extends ObjectQL> {
driver: SqlDriver;
/** The tables the boot created, sorted. */
tables: readonly string[];
/** The `sys_organization` rows the boot wrote, serialized, when the pool keeps them. */
bootOrganizations?: string;
held: boolean;
}

Expand All @@ -80,6 +90,11 @@ const ORGANIZATION_TABLE = 'sys_organization';

type Knex = ReturnType<SqlDriver['getKnex']>;

async function organizationRows(knex: Knex): Promise<string> {
const rows = (await knex(ORGANIZATION_TABLE).select('*')) as Array<Record<string, unknown>>;
return JSON.stringify(rows.map((row) => JSON.stringify(row)).sort());
}

async function tableNames(knex: Knex): Promise<string[]> {
const rows = (await knex('sqlite_master').where({ type: 'table' }).select('name')) as Array<{ name: string }>;
return rows
Expand Down Expand Up @@ -121,7 +136,11 @@ export function sqliteEnginePool<E extends ObjectQL>(
'memory across a reset; boot that engine per case instead of pooling it.',
);
}
const entry: Entry<E> = { engine, driver, tables: await tableNames(driver.getKnex()), held: true };
const tables = await tableNames(driver.getKnex());
const entry: Entry<E> = { engine, driver, tables, held: true };
if (options.keepBootOrganizations && tables.includes(ORGANIZATION_TABLE)) {
entry.bootOrganizations = await organizationRows(driver.getKnex());
}
entries.push(entry);
return entry;
}
Expand All @@ -138,7 +157,15 @@ export function sqliteEnginePool<E extends ObjectQL>(
'Boot that case its own engine.',
);
}
if (tables.includes(ORGANIZATION_TABLE)) {
const keptOrganizations = entry.bootOrganizations !== undefined;
if (keptOrganizations && (await organizationRows(knex)) !== entry.bootOrganizations) {
throw new Error(
`sqliteEnginePool: a case changed the ${ORGANIZATION_TABLE} rows this pool keeps from the boot, and the ` +
'engine memoizes the organization set until an engine write to that object invalidates it. Boot that ' +
'case its own engine.',
);
}
if (tables.includes(ORGANIZATION_TABLE) && !keptOrganizations) {
const [row] = (await knex(ORGANIZATION_TABLE).count({ n: '*' })) as Array<{ n: number | string }>;
if (Number(row?.n ?? 0) > 0) {
throw new Error(
Expand All @@ -151,7 +178,10 @@ export function sqliteEnginePool<E extends ObjectQL>(
await knex.transaction(async (trx) => {
// Checked at commit, when every table is empty: the delete order does not matter.
await trx.raw('PRAGMA defer_foreign_keys = ON');
for (const table of tables) await trx(table).del();
for (const table of tables) {
if (keptOrganizations && table === ORGANIZATION_TABLE) continue;
await trx(table).del();
}
const [sequence] = (await trx('sqlite_master').where({ type: 'table', name: 'sqlite_sequence' })) as unknown[];
if (sequence) await trx('sqlite_sequence').del();
});
Expand Down
89 changes: 69 additions & 20 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objec
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
import { SettingsService, brandingSettingsManifest } from '@objectstack/service-settings';

/**
* [ADR-0131 D3/D5] The `single` first-user promotion's grant, owned by the
* Default Organization the boot invariant creates before any user: its owner is
* read off `tables` as the Default Organization stands (`slug = 'default'`, else
* the only one) the first time one exists, and kept from then on — so the
* fixture holds the owner `start()` gives it rather than an organization-less
* row no reader anchors on, and a Default Organization deleted later leaves the
* grant naming the organization that is gone, as a stored row would.
*/
function promotionGrant(tables: Record<string, any[]>, userId: string): Record<string, unknown> {
let owner: string | undefined;
return {
id: 'ups1',
user_id: userId,
permission_set_id: 'ps_admin',
permission_set: 'admin_full_access',
get organization_id() {
const orgs = tables.sys_organization ?? [];
owner ??= (orgs.find((o) => o.slug === 'default') ?? (orgs.length === 1 ? orgs[0] : undefined))?.id;
return owner;
},
};
}

describe('AuthPlugin', () => {
let mockContext: PluginContext;
let authPlugin: AuthPlugin;
Expand Down Expand Up @@ -1438,12 +1462,11 @@ describe('AuthPlugin', () => {
const makeQl = () => {
const tables: Record<string, any[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
sys_user_permission_set: [
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
sys_user_permission_set: [],
sys_member: [],
sys_organization: [],
};
tables.sys_user_permission_set.push(promotionGrant(tables, 'u1'));
const matches = (row: any, where: any) =>
Object.entries(where ?? {}).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
Expand Down Expand Up @@ -1693,9 +1716,7 @@ describe('AuthPlugin', () => {
const makeQl = () => {
const tables: Record<string, any[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
sys_user_permission_set: [
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
sys_user_permission_set: [],
// The platform admin already exists; the default-org bootstrap binds it
// as `owner` on kernel:ready. A member-less seeded user is added later.
sys_user: [{ id: 'admin' }],
Expand All @@ -1704,6 +1725,7 @@ describe('AuthPlugin', () => {
// The deployment ledger the one-time pass records itself in.
sys_migration: [],
};
tables.sys_user_permission_set.push(promotionGrant(tables, 'admin'));
const matches = (row: any, where: any) =>
Object.entries(where ?? {}).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
Expand Down Expand Up @@ -1836,17 +1858,30 @@ describe('AuthPlugin', () => {
expect(ql.tables.sys_organization).toHaveLength(0);
expect(ql.tables.sys_migration).toHaveLength(0);

// 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', 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' });
});
expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([
'adr-0093-default-org-owner-bind',
'adr-0093-membership-backfill',
]);
// The declared owner's verified account lands: the bootstrap creates the
// organization, and that moment runs the pass — no restart, no
// app:seeded. [ADR-0131 D5] The config anchor is the population that
// needs no organization first; a grant anchors only once the Default
// Organization owns it.
const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
try {
Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), {
email: 'operator@corp.example',
email_verified: true,
});
await fireBootstrapWrite({ object: 'sys_user', operation: 'update', data: { email_verified: true } });
await vi.waitFor(() => {
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' });
});
expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([
'adr-0093-default-org-owner-bind',
'adr-0093-membership-backfill',
]);
} finally {
if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner;
}
});

it('unreadable ledger + an existing default organization + an unbound admin: the admin is NOT bound', async () => {
Expand Down Expand Up @@ -1927,9 +1962,23 @@ describe('AuthPlugin', () => {
expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind');
ql.tables.sys_organization.length = 0;
ql.tables.sys_member.length = 0;
await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' });
expect(ql.tables.sys_organization).toHaveLength(1);
expect(ql.tables.sys_member).toHaveLength(0);
// [ADR-0131 D5] The grant still names the organization that is gone, so
// it anchors nobody now; the declared, verified owner is who the
// bootstrap recreates the organization for.
const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
try {
Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), {
email: 'operator@corp.example',
email_verified: true,
});
await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' });
expect(ql.tables.sys_organization).toHaveLength(1);
expect(ql.tables.sys_member).toHaveLength(0);
} finally {
if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner;
}
});

it('once the pass is recorded, a later trigger binds nobody', async () => {
Expand Down
Loading
Loading