From 4be93984559bb3297a0aad74c7ede8deab522ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:34:21 +0000 Subject: [PATCH 1/3] wip(core,plugin-auth,plugin-security,cli): one Default Organization definition; the last-admin guard judges a sys_organization insert Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude --- .../src/utils/organization-ownership-plan.ts | 28 +-- .../core/src/security/default-organization.ts | 73 ++++++++ packages/core/src/security/index.ts | 14 ++ .../src/security/resolve-authz-context.ts | 30 ++-- .../src/default-organization-invariant.ts | 21 ++- .../src/ensure-default-organization.ts | 22 ++- .../plugin-auth/src/last-admin-guard.ts | 159 ++++++++++++++---- .../plugin-auth/src/tenancy-service.ts | 19 ++- .../src/suggested-audience-bindings.ts | 25 +-- 9 files changed, 289 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/security/default-organization.ts diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index f8f8801f998..98d9d4f49af 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { createHash } from 'node:crypto'; -import { emailTemplateDefinitionFromRow } from '@objectstack/core'; +import { DEFAULT_ORGANIZATION_SLUG, emailTemplateDefinitionFromRow, findDefaultOrganization } from '@objectstack/core'; import { dialectOfDriver } from '@objectstack/metadata-core'; import { hasPlatformObjectPrefix } from '@objectstack/spec/system'; import { @@ -490,7 +490,6 @@ const ORG = 'organization_id'; const ID = 'id'; const PAGE = 500; const IN_CHUNK = 200; -const DEFAULT_ORGANIZATION_SLUG = 'default'; /** A name the plan may interpolate as a quoted identifier. */ export const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -1336,20 +1335,27 @@ export async function buildOrganizationOwnershipPlan(opts: { // them `default`, name none: there is no single owner to derive, and the // rows are reported. ⛔ One definition: the ceremony attributing to an // organization the boot would not (or the reverse) leaves rows the post-check - // calls blocking on a database v18 would serve. The "one definition of the - // Default Organization" pin (organization-ownership-apply.test.ts) holds the - // two together over the same fixtures. + // calls blocking on a database v18 would serve. So the rule is the one + // `@objectstack/core` exports (`findDefaultOrganization`), and only the read + // is the ceremony's own: raw SQL against the database it certifies, with no + // engine, where a store with no `slug` column has no `default` to find. The + // "one definition of the Default Organization" pin + // (organization-ownership-apply.test.ts) holds the two together over the same + // fixtures. const hasSlug = hasOrganizations && catalog.columns.get('sys_organization')?.has('slug') === true; let defaultOrganization: { id: string; slug: string | null } | null = null; if (hasOrganizations) { const t = sql.q('sys_organization', 'sys_organization'); const select = `SELECT ${sql.q(ID, 'sys_organization')} AS id${hasSlug ? `, ${sql.q('slug', 'sys_organization')} AS slug` : ''} FROM ${t}`; - const bySlug = hasSlug - ? await sql.read('sys_organization', `${select} WHERE ${sql.q('slug', 'sys_organization')} = ?`, [DEFAULT_ORGANIZATION_SLUG]) - : []; - const named = bySlug.length > 0 ? bySlug : await sql.read('sys_organization', `${select} ORDER BY ${sql.q(ID, 'sys_organization')} LIMIT 2`, []); - const id = bySlug.length > 0 || named.length === 1 ? nonEmpty(named[0]?.id) : null; - if (id !== null) defaultOrganization = { id, slug: hasSlug ? nonEmpty(named[0]!.slug) : null }; + const found = await findDefaultOrganization(async (where, limit) => { + if ('slug' in where) { + return hasSlug + ? sql.read('sys_organization', `${select} WHERE ${sql.q('slug', 'sys_organization')} = ?`, [where.slug]) + : []; + } + return sql.read('sys_organization', `${select} ORDER BY ${sql.q(ID, 'sys_organization')} LIMIT ${Number(limit)}`, []); + }); + if (found.id !== undefined) defaultOrganization = { id: found.id, slug: hasSlug ? nonEmpty(found.row?.slug) : null }; } const defaultOrganizationId = defaultOrganization?.id ?? null; // [ADR-0131 D3, D10 fate 3] Under `single` the Default Organization owns diff --git a/packages/core/src/security/default-organization.ts b/packages/core/src/security/default-organization.ts new file mode 100644 index 00000000000..761ecaa281b --- /dev/null +++ b/packages/core/src/security/default-organization.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D5] The ONE definition of the deployment's Default + * Organization: the organization with `slug = 'default'`, else the only + * organization there is. Several organizations, none of them `default`, name + * none; so does a deployment that holds no organization. + * + * Under `single` it is the owner every organization-less write derives to + * (D9) and the owner of the `admin_full_access` grant that anchors platform + * standing (D5), so every reader must name the same row: a reader that named + * another one would have the boot create, the promotion write, the resolver + * read and the last-admin guard protect four different anchors. + * + * What differs between the readers is HOW they read, never what they pick: + * the resolver fails loud on an unreachable store, the bootstrap reads an + * unreadable store as nobody, the last-admin guard simulates a pending write + * over the rows it reads, and the ownership ceremony reads raw SQL with no + * engine. So the rule takes the read as a parameter + * ({@link DefaultOrganizationRead}) and each reader keeps its own read policy. + * ⛔ Never restate the rule beside a read: hand the read to + * {@link findDefaultOrganization}. + */ + +/** [ADR-0131 D3] The slug the boot invariant creates the Default Organization with. */ +export const DEFAULT_ORGANIZATION_SLUG = 'default'; + +/** + * One read of `sys_organization` the definition asks for: rows matching + * `where`, at most `limit` of them. It asks two: `{ slug: 'default' }` with + * limit 1, then `{}` with limit 2 (enough to tell "the only one" from + * "several"). A reader that simulates a pending write answers with the rows as + * that write would leave them. + */ +export type DefaultOrganizationRead = ( + where: Readonly>, + limit: number, +) => Promise; + +/** What {@link findDefaultOrganization} answers. */ +export interface DefaultOrganizationAnswer { + /** The Default Organization's id, or `undefined` when none can be named. */ + id: string | undefined; + /** The row it was named from, as the read returned it. */ + row: R | undefined; + /** + * `true` when none could be named because there are several organizations + * and none is `default` — the shape the boot invariant reports as ambiguous + * rather than creating one beside them. + */ + several: boolean; +} + +/** A row's id as the definition reads it: a non-empty string, or nothing. */ +function idOf(row: unknown): string | undefined { + const id = (row as { id?: unknown } | null | undefined)?.id; + return id === undefined || id === null || String(id) === '' ? undefined : String(id); +} + +/** + * [ADR-0131 D3/D5] Name the Default Organization through `read`. A read that + * throws propagates: the caller's read policy decides what an unreadable store + * means. + */ +export async function findDefaultOrganization(read: DefaultOrganizationRead): Promise> { + const bySlug = await read({ slug: DEFAULT_ORGANIZATION_SLUG }, 1); + const named = idOf(bySlug[0]); + if (named !== undefined) return { id: named, row: bySlug[0], several: false }; + const all = await read({}, 2); + const sole = all.length === 1 ? idOf(all[0]) : undefined; + if (sole !== undefined) return { id: sole, row: all[0], several: false }; + return { id: undefined, row: undefined, several: all.length > 1 }; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index b8a1553d683..bec1a68dbd5 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -211,6 +211,9 @@ export { // gate that raises D4's located refusal — one step, two askers. resolveGuestOrganization, resolveLocalizationContext, + // [ADR-0131 D3/D5] The Default Organization as the resolver reads it — the + // anchor of `single`-posture platform standing. + readDefaultOrganizationId, type ResolvedAuthzContext, type ResolveAuthzInput, type UserAuthzGrants, @@ -218,6 +221,17 @@ export { type ResolveLocalizationInput, } from './resolve-authz-context.js'; +// [ADR-0131 D3/D5] The ONE definition of the Default Organization (`slug = +// 'default'`, else the only organization), over a read each reader supplies: +// the boot invariant, the bootstrap, the tenancy service, the last-admin guard, +// the promotion and the ownership ceremony name the row the resolver names. +export { + DEFAULT_ORGANIZATION_SLUG, + findDefaultOrganization, + type DefaultOrganizationRead, + type DefaultOrganizationAnswer, +} from './default-organization.js'; + // [ADR-0131 D9, ADR-0138 D4] The ONE "which organization does a caller with no // organization of its own act in?" decision. Two askers: the engine's // system-context insert (`@objectstack/objectql`, which re-exports these same diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 6d3b1baf715..112b541e8e7 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -72,6 +72,7 @@ import { isGrantActive, nextGrantValidityBoundary } from './grant-validity.js'; import { openUserGrantsCache } from './resolve-user-grants-cache.js'; import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-memo.js'; import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js'; +import { findDefaultOrganization } from './default-organization.js'; import { derivePosture } from './posture-ladder.js'; import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js'; import { resolveSystemWriteOrganization, type SystemWriteOrganizationDecision } from './system-write-organization.js'; @@ -949,25 +950,26 @@ function grantAnchorsPlatformStanding( } /** - * [ADR-0131 D3/D5] The deployment's Default Organization: the one with - * `slug = 'default'`, else the only organization there is; `undefined` when - * neither exists (several organizations, none of them `default`, name none). - * The definition plugin-auth's boot invariant (`default-organization-invariant.ts`) - * creates the organization by, read-only here — and the one plugin-security's - * promotion resolves the owner of the grant it writes by, so the row it writes - * is the row this resolver reads as the anchor. + * [ADR-0131 D3/D5] The deployment's Default Organization, as this resolver + * reads it: the one definition ({@link findDefaultOrganization}: `slug = + * 'default'`, else the only organization) over this file's own read + * ({@link tryFind}), so a store that cannot be reached fails loud + * (`AuthzStoreUnavailableError`) and one never provisioned holds none. + * `undefined` when none can be named. plugin-auth's boot invariant creates the + * organization by the same definition, and plugin-security's promotion + * resolves the owner of the grant it writes by it, so the row it writes is the + * row this resolver reads as the anchor. * * A composition that registers no organization object holds none, and is not * read: the engine refuses an unregistered name as "object not found". + * + * Exported for the readers that read an engine under this policy. A reader + * with another read policy hands its own read to `findDefaultOrganization`. */ -async function readDefaultOrganizationId(ql: any): Promise { +export async function readDefaultOrganizationId(ql: any): Promise { if (!registryHoldsObject(ql, SystemObjectName.ORGANIZATION)) return undefined; - const idOf = (row: any): string | undefined => - row?.id === undefined || row?.id === null || String(row.id) === '' ? undefined : String(row.id); - const bySlug = await tryFind(ql, SystemObjectName.ORGANIZATION, { slug: 'default' }, 1); - if (idOf(bySlug[0])) return idOf(bySlug[0]); - const all = await tryFind(ql, SystemObjectName.ORGANIZATION, {}, 2); - return all.length === 1 ? idOf(all[0]) : undefined; + const found = await findDefaultOrganization((where, limit) => tryFind(ql, SystemObjectName.ORGANIZATION, where, limit)); + return found.id; } /** diff --git a/packages/plugins/plugin-auth/src/default-organization-invariant.ts b/packages/plugins/plugin-auth/src/default-organization-invariant.ts index 0767795969a..a7bc166f38e 100644 --- a/packages/plugins/plugin-auth/src/default-organization-invariant.ts +++ b/packages/plugins/plugin-auth/src/default-organization-invariant.ts @@ -21,6 +21,8 @@ * creates it — through this function, so there is one producer of the row. */ +import { DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization } from '@objectstack/core'; + const SYSTEM_CTX = { isSystem: true }; /** The logger channel this module writes; `AuthPlugin` passes its kernel logger. */ @@ -81,23 +83,24 @@ export async function ensureDefaultOrganizationExists( ql: any, options: { logger?: InvariantLogger } = {}, ): Promise { - const read = async (where: Record, limit: number) => + // [ADR-0131 D3/D5] The find half is the one definition (`findDefaultOrganization`, + // `@objectstack/core`) over this function's own read, which PROPAGATES a + // failure: the reader is shared, the read policy is this producer's. + const read = async (where: Readonly>, limit: number) => rowsOf(await ql.find('sys_organization', { where, limit }, { context: SYSTEM_CTX })); - const bySlug = await read({ slug: 'default' }, 1); - if (bySlug[0]?.id) return { organizationId: String(bySlug[0].id), created: false }; - const existing = await read({}, 2); - if (existing.length === 1 && existing[0]?.id) { - return { organizationId: String(existing[0].id), created: false }; - } - if (existing.length > 1) return { created: false, ambiguous: { organizationCount: existing.length } }; + const found = await findDefaultOrganization(read); + if (found.id !== undefined) return { organizationId: found.id, created: false }; + // The definition reads at most two rows to tell "the only one" from + // "several", so two is the count it measured. + if (found.several) return { created: false, ambiguous: { organizationCount: 2 } }; const newOrgId = genId('org'); let row: any; try { row = await ql.insert( 'sys_organization', - { id: newOrgId, name: 'Default Organization', slug: 'default', logo: null, metadata: null }, + { id: newOrgId, name: 'Default Organization', slug: DEFAULT_ORGANIZATION_SLUG, logo: null, metadata: null }, { context: SYSTEM_CTX }, ); } catch (e) { diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 345d358171d..b7106451445 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -82,7 +82,7 @@ * the owner bind. */ -import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; +import { DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization, matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; @@ -333,17 +333,15 @@ export interface EnsureDefaultOrganizationResult { } /** - * The Default Organization the way the `tenancy` service resolves it - * (`resolveDefaultOrgId`): the stable `slug='default'` organization, else the - * install's sole organization. `undefined` when there is neither — none, or - * several with no `default` among them. Reads through {@link tryFind}, so an - * unreadable store answers `undefined` and nothing is promoted on it. + * [ADR-0131 D3/D5] The Default Organization: the one definition + * (`findDefaultOrganization`, `@objectstack/core` — the stable `slug='default'` + * organization, else the install's sole organization) over {@link tryFind}. + * `undefined` when there is neither — none, or several with no `default` among + * them. The read is this module's: an unreadable store answers `undefined` and + * nothing is promoted on it, where the resolver's read would fail loud. */ async function findDefaultOrganizationId(ql: any): Promise { - const bySlug = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1); - if (bySlug[0]?.id) return String(bySlug[0].id); - const any = await tryFind(ql, 'sys_organization', {}, 2); - return any.length === 1 && any[0]?.id ? String(any[0].id) : undefined; + return (await findDefaultOrganization((where, limit) => tryFind(ql, 'sys_organization', where, limit))).id; } /** @@ -554,7 +552,7 @@ export async function ensureDefaultOrganization( let defaultOrgId: string | undefined; let defaultOrgCreated = false; const existingDefaultId = legacyGrantAnchorRetired - ? (await tryFind(ql, 'sys_organization', { slug: 'default' }, 1)) + ? (await tryFind(ql, 'sys_organization', { slug: DEFAULT_ORGANIZATION_SLUG }, 1)) .map((row) => (row?.id ? String(row.id) : undefined))[0] : await findDefaultOrganizationId(ql); if (existingDefaultId) { @@ -569,7 +567,7 @@ export async function ensureDefaultOrganization( const orgRow = await tryInsert(ql, 'sys_organization', { id: newOrgId, name: 'Default Organization', - slug: 'default', + slug: DEFAULT_ORGANIZATION_SLUG, logo: null, metadata: null, }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 09d7c2b9510..5aef66fb5c3 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -80,8 +80,8 @@ * So the invariant is enforced at the WRITE, on the chokepoints every path goes * through — `beforeUpdate` and `beforeDelete` on `sys_user`, `sys_member`, * `sys_user_permission_set` and `sys_organization` (which names the Default - * Organization), and `beforeInsert` / `beforeUpdate` on the activation - * ledger — rather than at any + * Organization, so its `beforeInsert` is judged too), and `beforeInsert` / + * `beforeUpdate` on the activation ledger — rather than at any * individual endpoint. * HTTP-level guards protect only the endpoints they are attached to; these hold * for the admin ban / remove endpoints, `updateMemberRole`, the SCIM adapter @@ -325,6 +325,7 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { SystemObjectName, SystemUserId } from '@objectstack/spec/system'; import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; import { + findDefaultOrganization, isGrantActive, matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails, @@ -379,6 +380,13 @@ export interface LastAdminGuardOptions { const DEFAULT_MAX_SCAN = 1000; +/** + * [ADR-0131 D5] The id an inserted organization is simulated under when its + * payload names none yet. No grant can name it, so it anchors nobody's + * standing — the one property the simulation needs of it. + */ +const INSERTED_ORGANIZATION_ID = '(the organization being created)'; + /** Reads run as system: this is a safety proof, never RLS-scoped to a caller. */ const SYSTEM_READ: BaseEngineOptions = { context: { isSystem: true } }; @@ -405,6 +413,7 @@ type GuardedOp = | 'activation-insert' | 'activation-update' | 'user-standing-update' + | 'organization-insert' | 'organization-update' | 'organization-delete'; @@ -419,6 +428,12 @@ interface OpWords { subject: string; /** The table this op writes — what a refusal reports as `err.object`. */ table: string; + /** + * How this write takes the standing away, for a write whose row is not + * itself the evidence — `accounts` reads "that account" or "those accounts". + * Absent: the row is the evidence, and the refusal says so. + */ + moves?: (accounts: string) => string; } const OP_WORDS: Record = { @@ -501,7 +516,21 @@ const OP_WORDS: Record = { // [ADR-0131 D5] The organization whose `admin_full_access` grant anchors // platform standing under `single` is the Default Organization, named by its // `slug` (or by being the only one) — so re-slugging or deleting it is a - // standing write. + // standing write, and so is adding one: a second organization beside a sole + // one not slugged `default` leaves no Default Organization, and one slugged + // `default` becomes it. + 'organization-insert': { + noun: 'organization creation', + verb: 'create', + gerund: 'creating', + Verb: 'Create', + subject: 'organizations', + table: SystemObjectName.ORGANIZATION, + moves: (accounts) => + `A new '${SystemObjectName.ORGANIZATION}' row moves this deployment's Default Organization off ` + + `the organization whose '${ADMIN_FULL_ACCESS}' grant is what MAKES ${accounts} an administrator, ` + + 'so creating it has the same end state as revoking that grant', + }, 'organization-update': { noun: 'organization change', verb: 'change', @@ -531,7 +560,16 @@ const OP_WORDS: Record = { * does. Sending THAT operator to look at an IdP group would send them into a * system they may not even run. */ -function standingOrigin(table: string, noun: string): string { +function standingOrigin(table: string, noun: string, op?: GuardedOp): string { + if (op === 'organization-insert') { + return ( + 'Under the single tenancy posture the Default Organization is the one slugged \'default\', else ' + + 'the only organization, so a second organization beside a sole one with another slug leaves ' + + 'none, and a new one slugged \'default\' takes its place. Grant another account administrator ' + + 'standing first, or give the current Default Organization the slug \'default\' first (while it ' + + 'is the only organization that changes nothing else), then create the organization.' + ); + } if (table === METADATA_ACTIVATION) { return ( `If the ${noun} came from a disable action, an upgrade step or a script, revoke the ` + @@ -626,8 +664,10 @@ interface PendingStandingWrite { /** The update payload, or `undefined` for a delete. */ patch?: Record; /** - * [ADR-0131 D3] A row this write INSERTS — the activation ledger's only - * standing write that addresses no existing row (`ids` is then empty). + * [ADR-0131 D3] A row this write INSERTS — a standing write that addresses + * no existing row (`ids` is then empty): an activation-ledger row switched + * off, or (ADR-0131 D5) an organization, which can move the Default + * Organization. */ inserted?: Record; /** @@ -997,14 +1037,20 @@ export function registerLastAdminGuard( }; /** - * [ADR-0131 D5] The Default Organization once `pending` lands — the - * resolver's definition (`readDefaultOrganizationId`, - * `resolve-authz-context.ts`): the organization with `slug = 'default'`, else - * the only one; `undefined` when neither exists. With a pending organization - * write, the rows it addresses are read as well — an update can move a row - * onto or off `default`, a delete removes it — and judged as they would read - * after it. A composition that registers no organization object holds none - * (the read is not issued). + * [ADR-0131 D5] The Default Organization once `pending` lands: the one + * definition (`findDefaultOrganization`, `@objectstack/core` — the + * organization with `slug = 'default'`, else the only one; `undefined` when + * neither exists), the definition the resolver reads the anchor by. + * + * The read is the guard's own on purpose, and is why this does not call the + * resolver's `readDefaultOrganizationId`: it answers with the rows as + * `pending` would leave them. The rows a pending organization write addresses + * are read as well — an update can move a row onto or off `default`, a delete + * removes it — and an inserted row is added to both reads. Its reads also go + * through {@link scan}, so an unreadable or oversized store refuses the write + * ({@link failClosed}) where the resolver would answer the request. A + * composition that registers no organization object holds none (the read is + * not issued). */ const readDefaultOrganizationId = async ( op: GuardedOp, @@ -1023,21 +1069,27 @@ export function registerLastAdminGuard( if (missing.length === 0) return rows; return [...rows, ...(await scan(op, SystemObjectName.ORGANIZATION, { where: { id: { $in: missing } }, fields }))]; }; - const after = (rows: Array>) => - rows.map((row) => applyPending(row, pending, SystemObjectName.ORGANIZATION)) + const after = (rows: Array>) => { + const kept = rows.map((row) => applyPending(row, pending, SystemObjectName.ORGANIZATION)) .filter((row): row is Record => row !== undefined); - const bySlug = after(await addressed(await scan(op, SystemObjectName.ORGANIZATION, { where: { slug: 'default' }, fields }))) - .find((row) => row.slug === 'default' && toId(row.id)); - if (bySlug) return toId(bySlug.id); - // "The only organization" asks 0 / 1 / several, so it reads a page just wide - // enough to still tell several apart after the rows a pending delete removes. - const page = await engine.find( - SystemObjectName.ORGANIZATION, - { where: {}, fields, limit: 2 + (orgWrite?.ids.size ?? 0) }, - SYSTEM_READ, - ); - const remaining = after(Array.isArray(page) ? page : []); - return remaining.length === 1 ? toId(remaining[0]!.id) : undefined; + return orgWrite?.inserted ? [...kept, orgWrite.inserted] : kept; + }; + const found = await findDefaultOrganization(async (where, limit) => { + if ('slug' in where) { + const rows = after(await addressed(await scan(op, SystemObjectName.ORGANIZATION, { where: { slug: where.slug }, fields }))); + return rows.filter((row) => row.slug === where.slug && toId(row.id)); + } + // "The only organization" asks 0 / 1 / several, so it reads a page just + // wide enough to still tell several apart after the rows a pending + // delete removes. + const page = await engine.find( + SystemObjectName.ORGANIZATION, + { where: {}, fields, limit: limit + (orgWrite?.ids.size ?? 0) }, + SYSTEM_READ, + ); + return after(Array.isArray(page) ? page : []); + }); + return found.id; }; /** @@ -1645,12 +1697,15 @@ export function registerLastAdminGuard( `Refusing this ${words.noun}: it would revoke the administrator standing of ` + `${losing.map((id) => `'${id}'`).join(', ')}, ` + `${many ? 'who are the last administrators' : 'who is the last administrator'} this ` + - `system has that ${many ? 'are' : 'is'} not already banned. The ` + - `'${table}' row is what MAKES ${many ? 'those accounts' : 'that account'} an ` + - `administrator, so ${words.gerund} it has the same end state as ${words.gerund} ` + - `${many ? 'the users themselves' : 'the user themselves'}: nobody would be able to ` + + `system has that ${many ? 'are' : 'is'} not already banned. ` + + (words.moves + ? words.moves(many ? 'those accounts' : 'that account') + : `The '${table}' row is what MAKES ${many ? 'those accounts' : 'that account'} an ` + + `administrator, so ${words.gerund} it has the same end state as ${words.gerund} ` + + `${many ? 'the users themselves' : 'the user themselves'}`) + + `: nobody would be able to ` + `administer the system or restore anyone's access (${BREAK_GLASS_CITATION}). ` + - `${REMEDY} ${standingOrigin(table, words.noun)}`, + `${REMEDY} ${standingOrigin(table, words.noun, op)}`, words.table, ); }); @@ -1807,6 +1862,35 @@ export function registerLastAdminGuard( await enforceStanding('organization-delete', SystemObjectName.ORGANIZATION, ctx.input); }; + /** + * [ADR-0131 D5] The insert half. The Default Organization is the one slugged + * `default`, else the only one, so ADDING an organization can move it with no + * grant row touched: a second organization beside a sole one with another + * slug leaves none, and a new `default`-slugged one takes its place — either + * way the grant the promotion wrote no longer anchors. Judged like every + * other standing write, and refused only when it would leave nobody who can + * sign in and administer; an organization owner or admin, a configured + * administrator, or a `default`-slugged Default Organization that stays put + * admits it. ⛔ Not a standing-preservation rule: a platform administrator who + * also owns an organization keeps that, which the invariant counts, so the + * insert is admitted and the grant stops anchoring. + * + * Under a wall no grant anchors (`anchorOwnerTest`), so no organization row + * feeds the enumeration and the insert costs no reads. A batch insert is + * dispatched per row, so each row is judged against the stored rows without + * its siblings — which reads the anchor as moved no later than the whole + * batch would. + */ + const guardOrganizationInsert = async (rawCtx: unknown): Promise => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== SystemObjectName.ORGANIZATION) return; + if (postureEnforcesWall(resolveTenancyPosture())) return; + const data = (ctx.input?.data ?? {}) as Record; + // A payload that names no id yet is still an organization, and owns no grant. + const inserted = { ...data, id: toId(data.id) ?? INSERTED_ORGANIZATION_ID }; + await enforceStanding('organization-insert', SystemObjectName.ORGANIZATION, ctx.input, undefined, inserted); + }; + // Priority 20: AFTER the ADR-0092 identity write guard's checks (10), before // default-priority hooks (100) spend work on a write this may refuse. The // four standing hooks (#5978) and the two activation-ledger hooks (#6084) are @@ -1858,6 +1942,11 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeInsert', guardOrganizationInsert, { + object: SystemObjectName.ORGANIZATION, + priority: 20, + packageId, + }); engine.registerHook('beforeUpdate', guardOrganizationUpdate, { object: SystemObjectName.ORGANIZATION, priority: 20, @@ -1872,8 +1961,8 @@ export function registerLastAdminGuard( logger?.info( '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + - 'sys_user_permission_set (standing revocation), sys_organization (the Default ' + - 'Organization the grant anchor reads) and sys_metadata_activation ' + + 'sys_user_permission_set (standing revocation), sys_organization (creating, re-slugging ' + + 'or removing the Default Organization the grant anchor reads) and sys_metadata_activation ' + '(switching admin_full_access off) — ADR-0135 D5.2', ); diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts index f0012491afb..8d571db24d5 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -96,6 +96,7 @@ * bootstrap). */ +import { findDefaultOrganization } from '@objectstack/core'; import { postureEnforcesWall, type TenancyPosture, @@ -217,17 +218,17 @@ async function findRows( } /** - * Resolve the single-org default organization: prefer the stable `slug='default'` - * bootstrap org, else the sole org row when exactly one exists. Returns `null` - * when there is no unambiguous single org (none yet, or ≥2 — the latter is a - * multi-org shape and this should not have been called). + * Resolve the single-org default organization: the one definition + * (`findDefaultOrganization`, `@objectstack/core`, ADR-0131 D3/D5 — the stable + * `slug='default'` bootstrap org, else the sole org row when exactly one + * exists) over {@link findRows}. Returns `null` when there is no unambiguous + * single org (none yet, or ≥2 — the latter is a multi-org shape and this should + * not have been called). The read is this module's: a failed one reads as no + * rows, so it answers `null` rather than throwing. */ export async function resolveDefaultOrgId(engine: any): Promise { - const bySlug = await findRows(engine, 'sys_organization', { slug: 'default' }, 1); - if (bySlug[0]?.id) return String(bySlug[0].id); - const any = await findRows(engine, 'sys_organization', {}, 2); - if (any.length === 1 && any[0]?.id) return String(any[0].id); - return null; + const found = await findDefaultOrganization((where, limit) => findRows(engine, 'sys_organization', { ...where }, limit)); + return found.id ?? null; } /** diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index bf54b4f3b1e..c3e7065fa11 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -80,7 +80,7 @@ import { import type { TenancyPosture } from '@objectstack/spec/security'; import { describeAnchorForbiddenBits, postureEnforcesWall } from '@objectstack/spec/security'; import { EVERYONE_POSITION } from '@objectstack/spec'; -import { securityCatalogReaderOf } from '@objectstack/core'; +import { findDefaultOrganization, securityCatalogReaderOf } from '@objectstack/core'; import { METADATA_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'; import { PermissionDeniedError } from './errors.js'; import { readDeclaredCapabilityContext } from './declared-capability-context.js'; @@ -539,22 +539,23 @@ function declaredOwnerOf(entry: { packageId?: string }): string | undefined { /** * The deployment's Default Organization — the owner of every row of this - * record (ADR-0131 D3/D5). The one with `slug='default'`, else the only - * organization there is; `undefined` when neither exists. + * record (ADR-0131 D3/D5): the one definition (`findDefaultOrganization`, + * `@objectstack/core` — `slug='default'`, else the only organization there is) + * over this module's read. `undefined` when neither exists. Read-only: this + * module never creates one. * - * The same derivation as plugin-auth's Default Organization boot invariant - * (`default-organization-invariant.ts`), read-only: this module never creates - * one. + * The read is this module's on purpose: it reads under `scopedSystemCtx()`, + * the context every other read of this record runs under, and a failed read + * THROWS rather than reading as no organization — the promotion + * (`bootstrap-platform-admin.ts`) and the record's writers each decide what an + * unreadable store means. */ export async function resolveDefaultOrganizationId(ql: any): Promise { - const read = async (where: Record, limit: number): Promise => { - const rows = await ql.find(ORGANIZATION_OBJECT, { where, fields: ['id'], limit, context: scopedSystemCtx() }); + const read = async (where: Readonly>, limit: number): Promise => { + const rows = await ql.find(ORGANIZATION_OBJECT, { where: { ...where }, fields: ['id'], limit, context: scopedSystemCtx() }); return Array.isArray(rows) ? rows : []; }; - const bySlug = await read({ slug: 'default' }, 1); - if (typeof bySlug[0]?.id === 'string' && bySlug[0].id !== '') return bySlug[0].id; - const all = await read({}, 2); - return all.length === 1 && typeof all[0]?.id === 'string' && all[0].id !== '' ? all[0].id : undefined; + return (await findDefaultOrganization(read)).id; } /** Is `row` this record's — owned by the Default Organization? (module doc) */ From 40622bd5e73d6b40dc63d638574e1194def06dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:45:12 +0000 Subject: [PATCH 2/3] test(plugin-auth,cli): pin the sys_organization insert half and the ceremony's attribution of a NULL admin_full_access grant Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude --- .../organization-ownership-apply.test.ts | 67 ++++++++++++++--- ...t-admin-guard.default-organization.test.ts | 73 ++++++++++++++++++- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 40b3a0181d7..120d3e19bde 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -22,7 +22,7 @@ import { createHash } from 'node:crypto'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { permissionSetDefinitionFromRow } from '@objectstack/core'; +import { permissionSetDefinitionFromRow, readDefaultOrganizationId } from '@objectstack/core'; import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn, hashSpec } from '@objectstack/metadata-core'; import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; import { @@ -83,7 +83,7 @@ function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor { * Like the registry, it serves a saved definition only from the next boot on * ({@link boot}), never the moment it is saved. */ -function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { +function catalog(opts: { adminSetDeclared?: boolean } = {}): CatalogPort & MetadataDoorPort & TemplatePort & { saved: Map>; templates: Map>; /** Environment `view` saves, in order, with the package each was saved into. */ @@ -92,6 +92,11 @@ function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { } { const declared = new Map>([ ['permission|member_default', permissionSetDefinitionFromRow(MEMBER_DEFAULT_ROW)], + // The composed security plugin ships it; `adminSetDeclared: false` is a + // catalog composed without that plugin. + ...(opts.adminSetDeclared === false + ? [] + : [['permission|admin_full_access', permissionSetDefinitionFromRow(ADMIN_FULL_ACCESS_ROW)] as [string, Record]]), ['position|org_admin', { name: 'org_admin', label: 'Org Admin', delegatable: false, permissionSets: ['member_default'] }], ]); const saved = new Map>(); @@ -276,6 +281,14 @@ const MEMBER_DEFAULT_ROW = { system_permissions: '[]', }; +/** The platform set the `single` first-user promotion grants (plugin-security ships it). */ +const ADMIN_FULL_ACCESS_ROW = { + name: 'admin_full_access', + label: 'Full Access', + object_permissions: '{}', + system_permissions: '[]', +}; + /** A 17.x-shaped database carrying every fate, on the post-stage-8 shape. */ function seed(): void { db.exec(` @@ -290,7 +303,8 @@ function seed(): void { ('ps_null', NULL, 'member_default', 'Member', 'platform', 1, '{"crm_deal":{"allowRead":true}}', '[]'), ('ps_org', 'org_default', 'member_default', 'Member', 'package', 1, '{"crm_deal":{"allowRead":true}}', '[]'), ('ps_auth', 'org_default', 'field_auditor', 'Field Auditor', 'admin', 1, - '{"crm_deal":{"allowRead":true,"allowEdit":true}}', '["export_reports"]'); + '{"crm_deal":{"allowRead":true,"allowEdit":true}}', '["export_reports"]'), + ('ps_admin_mirror', NULL, 'admin_full_access', 'Full Access', 'platform', 1, '{}', '[]'); CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT, delegatable INTEGER, active INTEGER); INSERT INTO sys_position VALUES @@ -334,7 +348,11 @@ function seed(): void { ('grant_1', 'org_default', 'usr_1', 'ps_org', NULL), ('grant_2', NULL, 'usr_2', 'ps_null', NULL), ('grant_3', 'org_default', 'usr_3', 'ps_auth', NULL), - ('grant_4', 'org_default', 'usr_4', 'ps_null', 'member_default'); + ('grant_4', 'org_default', 'usr_4', 'ps_null', 'member_default'), + -- the first-user promotion's grant as an earlier release wrote it: no + -- organization (ADR-0131 D5 — the ceremony attributes it to the Default + -- Organization, the owner the resolver reads the anchor by) + ('grant_promoted', NULL, 'usr_admin', 'ps_admin_mirror', NULL); CREATE TABLE sys_business_unit (id TEXT PRIMARY KEY, organization_id TEXT, parent_business_unit_id TEXT); INSERT INTO sys_business_unit VALUES ('bu_b', 'org_b', NULL); CREATE TABLE sys_user_position (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT, position TEXT, business_unit_id TEXT); @@ -397,6 +415,21 @@ function accessBefore(): string[] { return [...out].sort(); } +/** + * The fixture database as an engine answers `find` — the one-argument + * spelling the authorization resolver's reads use — so a reader that takes an + * engine reads these same rows. + */ +const sqliteEngine = () => ({ + async find(object: string, query: { where?: Record; limit?: number }) { + const where = query.where ?? {}; + const keys = Object.keys(where); + return db.prepare( + `SELECT * FROM "${object}"${keys.length ? ` WHERE ${keys.map((k) => `"${k}" = ?`).join(' AND ')}` : ''} ORDER BY id LIMIT ${query.limit ?? 100}`, + ).all(...keys.map((k) => where[k] as string)) as Record[]; + }, +}); + /** Effective access AFTER — the v18 reading, on the next boot: every reference is a name, resolved in the catalog. */ async function accessAfter(c: ReturnType): Promise { c.boot(); @@ -519,6 +552,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { it('comes through with effective access identical, mirrors gone, owners written, the orphan reported and holding certification, counts reconciling', async () => { const before = accessBefore(); const countsBefore = counts(); + const defaultOrganizationBefore = await readDefaultOrganizationId(sqliteEngine()); const c = catalog(); const l = ledger(); @@ -582,6 +616,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { { id: 'grant_2', permission_set: 'member_default' }, { id: 'grant_3', permission_set: 'field_auditor' }, { id: 'grant_4', permission_set: 'member_default' }, + { id: 'grant_promoted', permission_set: 'admin_full_access' }, ]); expect(rows(`SELECT name FROM pragma_table_info('sys_user_permission_set') WHERE name = 'permission_set_id'`)).toEqual([]); expect(writes).toContain('ALTER TABLE sys_user_permission_set DROP COLUMN permission_set_id'); @@ -591,6 +626,14 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(rows(`SELECT organization_id FROM sys_attachment WHERE id = 'att_1'`)).toEqual([{ organization_id: 'org_b' }]); expect(rows(`SELECT organization_id FROM crm_deal WHERE id = 'deal_2'`)).toEqual([{ organization_id: 'org_default' }]); expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_2'`)).toEqual([{ organization_id: 'org_default' }]); + // [ADR-0131 D5] The promotion's organization-less `admin_full_access` grant + // is owned by the organization the resolver reads the anchor by — the + // answer of `readDefaultOrganizationId` (`@objectstack/core`) over this + // same database — so the grant that anchored platform standing before the + // ceremony is the one that anchors it after. + expect(defaultOrganizationBefore).toBe('org_default'); + expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_promoted'`)) + .toEqual([{ organization_id: defaultOrganizationBefore }]); // The unattributable row is still present, still NULL — never guessed, // never deleted — reported by id, and held with its count and remedy. @@ -922,7 +965,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(rows(`SELECT organization_id FROM sys_file WHERE id = 'file_1'`)).toEqual([{ organization_id: 'org_b' }]); expect(c.saved.size).toBe(0); expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached'); - expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 3 }]); + expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 4 }]); + // The promotion's organization-less grant is not attributed under a wall. + expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_promoted'`)).toEqual([{ organization_id: null }]); // The promotion runs under every posture; nothing is deleted before mirror deletion. expect([...c.templates.keys()].sort()).toEqual(['auth.reset_password|en-US', 'crm.memo|en-US', 'crm.note|en-US', 'crm.renewal|zh-CN']); expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 6 }]); @@ -2102,7 +2147,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de ('grant_nobypass', 'org_default', 'usr_admin', NULL, 'organization_admin_no_bypass'), ('grant_unknown', 'org_default', 'usr_9', NULL, 'zz_unknown_set'); `); - const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET }); const rewrite = step(report, 'rewrite'); expect(rewrite.status).toBe('blocked'); @@ -2128,7 +2173,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de ('grant_admin_legacy', 'org_default', 'usr_admin', 'ps_admin', NULL), ('grant_gone_legacy', 'org_default', 'usr_9', 'ps_gone', NULL); `); - const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET }); const rewrite = step(report, 'rewrite'); expect(rewrite.status).toBe('blocked'); @@ -2145,7 +2190,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de it('authored-conversion: the seeded everyone binding gets the composition, never removal; an undeclared position keeps its remedy', async () => { db.exec(v17Bindings); - const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET }); + const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET }); const conversion = step(report, 'authored-conversion'); expect(conversion.status).toBe('blocked'); @@ -2161,7 +2206,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de it('under a wall, the rewrite gate: the everyone binding gets the composition; an undeclared position gets the walled remedy', async () => { db.exec(v17Bindings); - const report = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET }); + const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET }); expect(step(report, 'authored-conversion').status).toBe('done'); const rewrite = step(report, 'rewrite'); @@ -2181,7 +2226,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de ['not said', undefined], ])('control (%s): authored-conversion keeps the remedy — the composition is the boot\'s answer, never inferred', async (_label, securityPlugin) => { db.exec(v17Bindings); - const conversion = step(await apply(catalog(), ledger(), { securityPlugin }), 'authored-conversion'); + const conversion = step(await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin }), 'authored-conversion'); expect(blockerOf(conversion, 'position everyone ')).toMatch(/or remove the bindings/); expect(conversion.blockers.join('\n')).not.toContain(COMPOSITION); }); @@ -2194,7 +2239,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de INSERT INTO sys_user_permission_set VALUES ('grant_admin', 'org_default', 'usr_admin', NULL, 'admin_full_access'); ${v17Bindings} `); - const rewrite = step(await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite'); + const rewrite = step(await apply(catalog({ adminSetDeclared: false }), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite'); expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/); // Not the pointer. This fake catalog declares no `everyone` (a composed boot declares it, sealed), so which // walled remedy follows is not this control's question. diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts index 3f4cf65d711..9ab85b679e6 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts @@ -12,8 +12,9 @@ * that ends the last one. * 2. **The guard judges the organization writes that move the anchor.** The * Default Organization is the one with `slug = 'default'`, else the only - * one — so re-slugging it while another organization exists, or deleting - * it, takes the standing away with no grant row touched. + * one — so re-slugging it while another organization exists, deleting it, + * or creating a second organization beside a sole one not slugged + * `default`, takes the standing away with no grant row touched. * * Same method as `last-admin-guard.config-anchor.test.ts`: a REAL ObjectQL * engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks. @@ -104,7 +105,9 @@ afterEach(async () => { * writes — owned by the Default Organization — and nothing else (no * membership, so the organization-owner half of the enumeration counts nobody). */ -async function promotedSingle(opts: { secondOrganization?: boolean; grantOwner?: string } = {}): Promise { +async function promotedSingle( + opts: { secondOrganization?: boolean; grantOwner?: string; defaultSlug?: string } = {}, +): Promise { const engine = new ObjectQL(); engines.push(engine); engine.registerDriver( @@ -117,7 +120,7 @@ async function promotedSingle(opts: { secondOrganization?: boolean; grantOwner?: bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); guard = registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard.default-org' }); - await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: opts.defaultSlug ?? 'default' }, SYSTEM); if (opts.secondOrganization) { await engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM); } @@ -175,6 +178,68 @@ describe('[ADR-0131 D5] the organization writes that move the anchor off that gr }); }); +describe('[ADR-0131 D5] creating an organization that moves the anchor off that grant is judged', () => { + // A sole organization is the Default Organization whatever its slug, so the + // fixture's `acme` organization owns the anchoring grant until a second + // organization arrives: then none is the Default Organization (the second + // one has another slug), or the second one is (it is slugged `default`). + const organizationIds = async (engine: ObjectQL) => + ((await engine.find('sys_organization', { where: {}, ...SYSTEM })) as Array>) + .map((row) => row.id) + .sort(); + + it('a second organization beside a sole one not slugged `default` is refused, and nothing is written', async () => { + const engine = await promotedSingle({ defaultSlug: 'acme' }); + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM)) + .rejects.toMatchObject({ ...refusal, object: 'sys_organization' }); + expect(await organizationIds(engine)).toEqual(['org_default']); + // The grant still anchors: its holder is still the last administrator. + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); + }); + + it('a new organization slugged `default` beside a sole one with another slug is refused', async () => { + const engine = await promotedSingle({ defaultSlug: 'acme' }); + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'default' }, SYSTEM)) + .rejects.toMatchObject({ ...refusal, object: 'sys_organization' }); + expect(await organizationIds(engine)).toEqual(['org_default']); + }); + + it('the refusal names the remedy that keeps the anchor: slug the current one `default` first, then the insert is admitted', async () => { + const engine = await promotedSingle({ defaultSlug: 'acme' }); + const err = await engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM) + .then(() => undefined, (e: unknown) => e as Error); + expect(err?.message).toContain('organization creation'); + await expect(engine.update('sys_organization', { id: 'org_default', slug: 'default' }, SYSTEM)).resolves.toBeDefined(); + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM)).resolves.toBeDefined(); + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); + }); + + it('CONTROL — a `default`-slugged Default Organization stays put, so a second organization is admitted', async () => { + const engine = await promotedSingle(); + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM)).resolves.toBeDefined(); + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); + }); + + it('CONTROL — an administrator left standing elsewhere admits the insert: the guard is break-glass, not standing preservation', async () => { + const engine = await promotedSingle({ defaultSlug: 'acme' }); + await engine.insert('sys_member', { id: 'mem_owner', user_id: 'usr_admin', organization_id: 'org_default', role: 'owner' }, SYSTEM); + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM)).resolves.toBeDefined(); + }); + + it('CONTROL — under a wall no grant anchors, so the insert is admitted without a read', async () => { + const engine = await promotedSingle({ defaultSlug: 'acme' }); + process.env[POSTURE_ENV] = 'isolated'; + const reads: string[] = []; + const find = engine.find.bind(engine); + (engine as unknown as { find: typeof engine.find }).find = ((object: string, ...rest: unknown[]) => { + reads.push(object); + return (find as (...a: unknown[]) => unknown)(object, ...rest); + }) as typeof engine.find; + await expect(engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM)).resolves.toBeDefined(); + expect(reads).toEqual([]); + }); +}); + describe('[#24948] the same question about deleting the Default Organization, asked before anything is deleted', () => { const members = (engine: ObjectQL) => engine.find('sys_member', { where: {}, ...SYSTEM }) as Promise>>; From 2c165f8b8fc804de206facdb83c7390b712b1bda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:46:45 +0000 Subject: [PATCH 3/3] test(core): pin the one Default Organization definition; changeset Claude-Session: https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV Co-authored-by: Claude --- ...208-default-organization-one-definition.md | 22 +++++ .../src/security/default-organization.test.ts | 80 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .changeset/25208-default-organization-one-definition.md create mode 100644 packages/core/src/security/default-organization.test.ts diff --git a/.changeset/25208-default-organization-one-definition.md b/.changeset/25208-default-organization-one-definition.md new file mode 100644 index 00000000000..ddbc9f38f06 --- /dev/null +++ b/.changeset/25208-default-organization-one-definition.md @@ -0,0 +1,22 @@ +--- +'@objectstack/core': patch +'@objectstack/plugin-auth': patch +'@objectstack/plugin-security': patch +'@objectstack/cli': patch +--- + +fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. + +- **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. + - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. + - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. +- **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. + +**What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. diff --git a/packages/core/src/security/default-organization.test.ts b/packages/core/src/security/default-organization.test.ts new file mode 100644 index 00000000000..5551be2aca3 --- /dev/null +++ b/packages/core/src/security/default-organization.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D5] The one definition of the Default Organization, pinned on + * its own: `slug = 'default'`, else the only organization, else none. Every + * reader (the resolver, plugin-auth's boot invariant, bootstrap, tenancy + * service and last-admin guard, plugin-security's promotion, the ownership + * ceremony) hands its read to this rule, so these cases are what all of them + * answer. + */ + +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization } from './default-organization.js'; +import { readDefaultOrganizationId } from './resolve-authz-context.js'; + +type Row = { id?: unknown; slug?: string }; + +/** A read over `rows` that answers the two questions the rule asks, and records them. */ +function readOver(rows: Row[]) { + const asked: Array<{ where: Readonly>; limit: number }> = []; + const read = async (where: Readonly>, limit: number): Promise => { + asked.push({ where, limit }); + return rows.filter((row) => !('slug' in where) || row.slug === where.slug).slice(0, limit); + }; + return { read, asked }; +} + +describe('[ADR-0131 D3/D5] findDefaultOrganization — the one definition', () => { + it('the organization slugged `default` wins, beside others, and the second read is never issued', async () => { + const { read, asked } = readOver([{ id: 'org_acme', slug: 'acme' }, { id: 'org_def', slug: 'default' }]); + expect(await findDefaultOrganization(read)).toEqual({ id: 'org_def', row: { id: 'org_def', slug: 'default' }, several: false }); + expect(asked).toEqual([{ where: { slug: DEFAULT_ORGANIZATION_SLUG }, limit: 1 }]); + }); + + it('else the only organization, whatever its slug', async () => { + const { read, asked } = readOver([{ id: 'org_acme', slug: 'acme' }]); + expect(await findDefaultOrganization(read)).toMatchObject({ id: 'org_acme', several: false }); + expect(asked).toEqual([{ where: { slug: 'default' }, limit: 1 }, { where: {}, limit: 2 }]); + }); + + it('several, none of them `default`, name none — and say so', async () => { + const { read } = readOver([{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]); + expect(await findDefaultOrganization(read)).toEqual({ id: undefined, row: undefined, several: true }); + }); + + it('no organization names none, and is not "several"', async () => { + const { read } = readOver([]); + expect(await findDefaultOrganization(read)).toEqual({ id: undefined, row: undefined, several: false }); + }); + + it('a row with no usable id names nothing: a blank-id `default` falls through to the only-organization reading', async () => { + expect((await findDefaultOrganization(readOver([{ id: '', slug: 'default' }]).read)).id).toBeUndefined(); + expect((await findDefaultOrganization(readOver([{ id: null, slug: 'default' }, { id: 'org_b', slug: 'b' }]).read)).id) + .toBeUndefined(); + }); + + it('a read that fails propagates: what an unreadable store means is the caller\'s read policy', async () => { + const boom = new Error('store unreachable'); + await expect(findDefaultOrganization(async () => { throw boom; })).rejects.toBe(boom); + }); +}); + +describe('[ADR-0131 D5] readDefaultOrganizationId — the resolver\'s read over the one definition', () => { + const engineOver = (rows: Row[], registered = true) => ({ + registry: { getObject: (name: string) => (registered && name === 'sys_organization' ? { name } : undefined) }, + async find(_object: string, query: { where?: Record; limit?: number }) { + return readOver(rows).read(query.where ?? {}, query.limit ?? 100); + }, + }); + + it('answers what the definition answers', async () => { + expect(await readDefaultOrganizationId(engineOver([{ id: 'org_acme', slug: 'acme' }]))).toBe('org_acme'); + expect(await readDefaultOrganizationId(engineOver([{ id: 'org_acme', slug: 'acme' }, { id: 'org_b', slug: 'b' }]))).toBeUndefined(); + }); + + it('a composition that registers no organization object holds none, and is not read', async () => { + expect(await readDefaultOrganizationId(engineOver([{ id: 'org_acme', slug: 'acme' }], false))).toBeUndefined(); + }); +});