Skip to content

Commit 4be9398

Browse files
committed
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 <noreply@anthropic.com>
1 parent e58bd0e commit 4be9398

9 files changed

Lines changed: 289 additions & 102 deletions

File tree

‎packages/cli/src/utils/organization-ownership-plan.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { createHash } from 'node:crypto';
4-
import { emailTemplateDefinitionFromRow } from '@objectstack/core';
4+
import { DEFAULT_ORGANIZATION_SLUG, emailTemplateDefinitionFromRow, findDefaultOrganization } from '@objectstack/core';
55
import { dialectOfDriver } from '@objectstack/metadata-core';
66
import { hasPlatformObjectPrefix } from '@objectstack/spec/system';
77
import {
@@ -490,7 +490,6 @@ const ORG = 'organization_id';
490490
const ID = 'id';
491491
const PAGE = 500;
492492
const IN_CHUNK = 200;
493-
const DEFAULT_ORGANIZATION_SLUG = 'default';
494493

495494
/** A name the plan may interpolate as a quoted identifier. */
496495
export const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -1336,20 +1335,27 @@ export async function buildOrganizationOwnershipPlan(opts: {
13361335
// them `default`, name none: there is no single owner to derive, and the
13371336
// rows are reported. ⛔ One definition: the ceremony attributing to an
13381337
// organization the boot would not (or the reverse) leaves rows the post-check
1339-
// calls blocking on a database v18 would serve. The "one definition of the
1340-
// Default Organization" pin (organization-ownership-apply.test.ts) holds the
1341-
// two together over the same fixtures.
1338+
// calls blocking on a database v18 would serve. So the rule is the one
1339+
// `@objectstack/core` exports (`findDefaultOrganization`), and only the read
1340+
// is the ceremony's own: raw SQL against the database it certifies, with no
1341+
// engine, where a store with no `slug` column has no `default` to find. The
1342+
// "one definition of the Default Organization" pin
1343+
// (organization-ownership-apply.test.ts) holds the two together over the same
1344+
// fixtures.
13421345
const hasSlug = hasOrganizations && catalog.columns.get('sys_organization')?.has('slug') === true;
13431346
let defaultOrganization: { id: string; slug: string | null } | null = null;
13441347
if (hasOrganizations) {
13451348
const t = sql.q('sys_organization', 'sys_organization');
13461349
const select = `SELECT ${sql.q(ID, 'sys_organization')} AS id${hasSlug ? `, ${sql.q('slug', 'sys_organization')} AS slug` : ''} FROM ${t}`;
1347-
const bySlug = hasSlug
1348-
? await sql.read('sys_organization', `${select} WHERE ${sql.q('slug', 'sys_organization')} = ?`, [DEFAULT_ORGANIZATION_SLUG])
1349-
: [];
1350-
const named = bySlug.length > 0 ? bySlug : await sql.read('sys_organization', `${select} ORDER BY ${sql.q(ID, 'sys_organization')} LIMIT 2`, []);
1351-
const id = bySlug.length > 0 || named.length === 1 ? nonEmpty(named[0]?.id) : null;
1352-
if (id !== null) defaultOrganization = { id, slug: hasSlug ? nonEmpty(named[0]!.slug) : null };
1350+
const found = await findDefaultOrganization(async (where, limit) => {
1351+
if ('slug' in where) {
1352+
return hasSlug
1353+
? sql.read('sys_organization', `${select} WHERE ${sql.q('slug', 'sys_organization')} = ?`, [where.slug])
1354+
: [];
1355+
}
1356+
return sql.read('sys_organization', `${select} ORDER BY ${sql.q(ID, 'sys_organization')} LIMIT ${Number(limit)}`, []);
1357+
});
1358+
if (found.id !== undefined) defaultOrganization = { id: found.id, slug: hasSlug ? nonEmpty(found.row?.slug) : null };
13531359
}
13541360
const defaultOrganizationId = defaultOrganization?.id ?? null;
13551361
// [ADR-0131 D3, D10 fate 3] Under `single` the Default Organization owns
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D3/D5] The ONE definition of the deployment's Default
5+
* Organization: the organization with `slug = 'default'`, else the only
6+
* organization there is. Several organizations, none of them `default`, name
7+
* none; so does a deployment that holds no organization.
8+
*
9+
* Under `single` it is the owner every organization-less write derives to
10+
* (D9) and the owner of the `admin_full_access` grant that anchors platform
11+
* standing (D5), so every reader must name the same row: a reader that named
12+
* another one would have the boot create, the promotion write, the resolver
13+
* read and the last-admin guard protect four different anchors.
14+
*
15+
* What differs between the readers is HOW they read, never what they pick:
16+
* the resolver fails loud on an unreachable store, the bootstrap reads an
17+
* unreadable store as nobody, the last-admin guard simulates a pending write
18+
* over the rows it reads, and the ownership ceremony reads raw SQL with no
19+
* engine. So the rule takes the read as a parameter
20+
* ({@link DefaultOrganizationRead}) and each reader keeps its own read policy.
21+
* ⛔ Never restate the rule beside a read: hand the read to
22+
* {@link findDefaultOrganization}.
23+
*/
24+
25+
/** [ADR-0131 D3] The slug the boot invariant creates the Default Organization with. */
26+
export const DEFAULT_ORGANIZATION_SLUG = 'default';
27+
28+
/**
29+
* One read of `sys_organization` the definition asks for: rows matching
30+
* `where`, at most `limit` of them. It asks two: `{ slug: 'default' }` with
31+
* limit 1, then `{}` with limit 2 (enough to tell "the only one" from
32+
* "several"). A reader that simulates a pending write answers with the rows as
33+
* that write would leave them.
34+
*/
35+
export type DefaultOrganizationRead<R> = (
36+
where: Readonly<Record<string, unknown>>,
37+
limit: number,
38+
) => Promise<readonly R[]>;
39+
40+
/** What {@link findDefaultOrganization} answers. */
41+
export interface DefaultOrganizationAnswer<R> {
42+
/** The Default Organization's id, or `undefined` when none can be named. */
43+
id: string | undefined;
44+
/** The row it was named from, as the read returned it. */
45+
row: R | undefined;
46+
/**
47+
* `true` when none could be named because there are several organizations
48+
* and none is `default` — the shape the boot invariant reports as ambiguous
49+
* rather than creating one beside them.
50+
*/
51+
several: boolean;
52+
}
53+
54+
/** A row's id as the definition reads it: a non-empty string, or nothing. */
55+
function idOf(row: unknown): string | undefined {
56+
const id = (row as { id?: unknown } | null | undefined)?.id;
57+
return id === undefined || id === null || String(id) === '' ? undefined : String(id);
58+
}
59+
60+
/**
61+
* [ADR-0131 D3/D5] Name the Default Organization through `read`. A read that
62+
* throws propagates: the caller's read policy decides what an unreadable store
63+
* means.
64+
*/
65+
export async function findDefaultOrganization<R>(read: DefaultOrganizationRead<R>): Promise<DefaultOrganizationAnswer<R>> {
66+
const bySlug = await read({ slug: DEFAULT_ORGANIZATION_SLUG }, 1);
67+
const named = idOf(bySlug[0]);
68+
if (named !== undefined) return { id: named, row: bySlug[0], several: false };
69+
const all = await read({}, 2);
70+
const sole = all.length === 1 ? idOf(all[0]) : undefined;
71+
if (sole !== undefined) return { id: sole, row: all[0], several: false };
72+
return { id: undefined, row: undefined, several: all.length > 1 };
73+
}

‎packages/core/src/security/index.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,27 @@ export {
211211
// gate that raises D4's located refusal — one step, two askers.
212212
resolveGuestOrganization,
213213
resolveLocalizationContext,
214+
// [ADR-0131 D3/D5] The Default Organization as the resolver reads it — the
215+
// anchor of `single`-posture platform standing.
216+
readDefaultOrganizationId,
214217
type ResolvedAuthzContext,
215218
type ResolveAuthzInput,
216219
type UserAuthzGrants,
217220
type ResolveUserAuthzGrantsOptions,
218221
type ResolveLocalizationInput,
219222
} from './resolve-authz-context.js';
220223

224+
// [ADR-0131 D3/D5] The ONE definition of the Default Organization (`slug =
225+
// 'default'`, else the only organization), over a read each reader supplies:
226+
// the boot invariant, the bootstrap, the tenancy service, the last-admin guard,
227+
// the promotion and the ownership ceremony name the row the resolver names.
228+
export {
229+
DEFAULT_ORGANIZATION_SLUG,
230+
findDefaultOrganization,
231+
type DefaultOrganizationRead,
232+
type DefaultOrganizationAnswer,
233+
} from './default-organization.js';
234+
221235
// [ADR-0131 D9, ADR-0138 D4] The ONE "which organization does a caller with no
222236
// organization of its own act in?" decision. Two askers: the engine's
223237
// system-context insert (`@objectstack/objectql`, which re-exports these same

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { isGrantActive, nextGrantValidityBoundary } from './grant-validity.js';
7272
import { openUserGrantsCache } from './resolve-user-grants-cache.js';
7373
import { openRequestGrantsMemo, withRequestGrantsMemo } from './request-grants-memo.js';
7474
import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from './platform-admin.js';
75+
import { findDefaultOrganization } from './default-organization.js';
7576
import { derivePosture } from './posture-ladder.js';
7677
import { securityCatalogReaderOf, type SecurityCatalogReader } from './security-catalog.js';
7778
import { resolveSystemWriteOrganization, type SystemWriteOrganizationDecision } from './system-write-organization.js';
@@ -949,25 +950,26 @@ function grantAnchorsPlatformStanding(
949950
}
950951

951952
/**
952-
* [ADR-0131 D3/D5] The deployment's Default Organization: the one with
953-
* `slug = 'default'`, else the only organization there is; `undefined` when
954-
* neither exists (several organizations, none of them `default`, name none).
955-
* The definition plugin-auth's boot invariant (`default-organization-invariant.ts`)
956-
* creates the organization by, read-only here — and the one plugin-security's
957-
* promotion resolves the owner of the grant it writes by, so the row it writes
958-
* is the row this resolver reads as the anchor.
953+
* [ADR-0131 D3/D5] The deployment's Default Organization, as this resolver
954+
* reads it: the one definition ({@link findDefaultOrganization}: `slug =
955+
* 'default'`, else the only organization) over this file's own read
956+
* ({@link tryFind}), so a store that cannot be reached fails loud
957+
* (`AuthzStoreUnavailableError`) and one never provisioned holds none.
958+
* `undefined` when none can be named. plugin-auth's boot invariant creates the
959+
* organization by the same definition, and plugin-security's promotion
960+
* resolves the owner of the grant it writes by it, so the row it writes is the
961+
* row this resolver reads as the anchor.
959962
*
960963
* A composition that registers no organization object holds none, and is not
961964
* read: the engine refuses an unregistered name as "object not found".
965+
*
966+
* Exported for the readers that read an engine under this policy. A reader
967+
* with another read policy hands its own read to `findDefaultOrganization`.
962968
*/
963-
async function readDefaultOrganizationId(ql: any): Promise<string | undefined> {
969+
export async function readDefaultOrganizationId(ql: any): Promise<string | undefined> {
964970
if (!registryHoldsObject(ql, SystemObjectName.ORGANIZATION)) return undefined;
965-
const idOf = (row: any): string | undefined =>
966-
row?.id === undefined || row?.id === null || String(row.id) === '' ? undefined : String(row.id);
967-
const bySlug = await tryFind(ql, SystemObjectName.ORGANIZATION, { slug: 'default' }, 1);
968-
if (idOf(bySlug[0])) return idOf(bySlug[0]);
969-
const all = await tryFind(ql, SystemObjectName.ORGANIZATION, {}, 2);
970-
return all.length === 1 ? idOf(all[0]) : undefined;
971+
const found = await findDefaultOrganization((where, limit) => tryFind(ql, SystemObjectName.ORGANIZATION, where, limit));
972+
return found.id;
971973
}
972974

973975
/**

‎packages/plugins/plugin-auth/src/default-organization-invariant.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
* creates it — through this function, so there is one producer of the row.
2222
*/
2323

24+
import { DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization } from '@objectstack/core';
25+
2426
const SYSTEM_CTX = { isSystem: true };
2527

2628
/** The logger channel this module writes; `AuthPlugin` passes its kernel logger. */
@@ -81,23 +83,24 @@ export async function ensureDefaultOrganizationExists(
8183
ql: any,
8284
options: { logger?: InvariantLogger } = {},
8385
): Promise<DefaultOrganizationInvariantResult> {
84-
const read = async (where: Record<string, unknown>, limit: number) =>
86+
// [ADR-0131 D3/D5] The find half is the one definition (`findDefaultOrganization`,
87+
// `@objectstack/core`) over this function's own read, which PROPAGATES a
88+
// failure: the reader is shared, the read policy is this producer's.
89+
const read = async (where: Readonly<Record<string, unknown>>, limit: number) =>
8590
rowsOf(await ql.find('sys_organization', { where, limit }, { context: SYSTEM_CTX }));
8691

87-
const bySlug = await read({ slug: 'default' }, 1);
88-
if (bySlug[0]?.id) return { organizationId: String(bySlug[0].id), created: false };
89-
const existing = await read({}, 2);
90-
if (existing.length === 1 && existing[0]?.id) {
91-
return { organizationId: String(existing[0].id), created: false };
92-
}
93-
if (existing.length > 1) return { created: false, ambiguous: { organizationCount: existing.length } };
92+
const found = await findDefaultOrganization(read);
93+
if (found.id !== undefined) return { organizationId: found.id, created: false };
94+
// The definition reads at most two rows to tell "the only one" from
95+
// "several", so two is the count it measured.
96+
if (found.several) return { created: false, ambiguous: { organizationCount: 2 } };
9497

9598
const newOrgId = genId('org');
9699
let row: any;
97100
try {
98101
row = await ql.insert(
99102
'sys_organization',
100-
{ id: newOrgId, name: 'Default Organization', slug: 'default', logo: null, metadata: null },
103+
{ id: newOrgId, name: 'Default Organization', slug: DEFAULT_ORGANIZATION_SLUG, logo: null, metadata: null },
101104
{ context: SYSTEM_CTX },
102105
);
103106
} catch (e) {

‎packages/plugins/plugin-auth/src/ensure-default-organization.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
* the owner bind.
8383
*/
8484

85-
import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core';
85+
import { DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization, matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core';
8686
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
8787
import { postureEnforcesWall } from '@objectstack/spec/security';
8888
import { resolveTenancyPosture } from '@objectstack/types';
@@ -333,17 +333,15 @@ export interface EnsureDefaultOrganizationResult {
333333
}
334334

335335
/**
336-
* The Default Organization the way the `tenancy` service resolves it
337-
* (`resolveDefaultOrgId`): the stable `slug='default'` organization, else the
338-
* install's sole organization. `undefined` when there is neither — none, or
339-
* several with no `default` among them. Reads through {@link tryFind}, so an
340-
* unreadable store answers `undefined` and nothing is promoted on it.
336+
* [ADR-0131 D3/D5] The Default Organization: the one definition
337+
* (`findDefaultOrganization`, `@objectstack/core` — the stable `slug='default'`
338+
* organization, else the install's sole organization) over {@link tryFind}.
339+
* `undefined` when there is neither — none, or several with no `default` among
340+
* them. The read is this module's: an unreadable store answers `undefined` and
341+
* nothing is promoted on it, where the resolver's read would fail loud.
341342
*/
342343
async function findDefaultOrganizationId(ql: any): Promise<string | undefined> {
343-
const bySlug = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1);
344-
if (bySlug[0]?.id) return String(bySlug[0].id);
345-
const any = await tryFind(ql, 'sys_organization', {}, 2);
346-
return any.length === 1 && any[0]?.id ? String(any[0].id) : undefined;
344+
return (await findDefaultOrganization((where, limit) => tryFind(ql, 'sys_organization', where, limit))).id;
347345
}
348346

349347
/**
@@ -554,7 +552,7 @@ export async function ensureDefaultOrganization(
554552
let defaultOrgId: string | undefined;
555553
let defaultOrgCreated = false;
556554
const existingDefaultId = legacyGrantAnchorRetired
557-
? (await tryFind(ql, 'sys_organization', { slug: 'default' }, 1))
555+
? (await tryFind(ql, 'sys_organization', { slug: DEFAULT_ORGANIZATION_SLUG }, 1))
558556
.map((row) => (row?.id ? String(row.id) : undefined))[0]
559557
: await findDefaultOrganizationId(ql);
560558
if (existingDefaultId) {
@@ -569,7 +567,7 @@ export async function ensureDefaultOrganization(
569567
const orgRow = await tryInsert(ql, 'sys_organization', {
570568
id: newOrgId,
571569
name: 'Default Organization',
572-
slug: 'default',
570+
slug: DEFAULT_ORGANIZATION_SLUG,
573571
logo: null,
574572
metadata: null,
575573
});

0 commit comments

Comments
 (0)