Skip to content

Commit 8dfd126

Browse files
fix(core,plugin-auth)!: the last-admin guard judges a sys_organization insert that moves the Default Organization; one Default Organization definition (#25265)
Fixes #25208 Clause-②: yes (narrowing) Metadata-protocol: none The three 8c-2 review carriers of record 6253205661 (owed scope of #15212, claim 6345294692). ## 1. The anchor guard: measured, then a `beforeInsert` half **Measurement first, on `main` at `e58bd0e3`.** I used a real ObjectQL engine over better-sqlite3, with the real last-admin guard and the real `resolveAuthzContext`. The `single` posture has a sole organization slugged `acme`, and an `admin_full_access` grant owned by it is the only standing of `usr_admin`. | write | before | after | |---|---|---| | (A) insert a second organization, slug `b` | `usr_admin` is `platform_admin` | the insert is admitted; `usr_admin` is no longer `platform_admin`; a ban of `usr_admin` is then **admitted** (the guard counts nobody, so it reads the bootstrap window) | | (B) insert an organization slugged `default`, then a grant of the set owned by it to `usr_b` | `usr_admin` admin, `usr_b` not | the insert is admitted; `usr_admin` loses standing; `usr_b` **gains** it | So the anchor can move. (A) is the fail-open the review named: the silent loss also disarms the guard for the next write. **What the code and ADR-0131 D5 support: refuse, judged like the update and delete halves.** D5 makes the Default Organization's grant the anchor under `single`. D3/D5 define the Default Organization as the one slugged `default`, else the only one. So keeping the anchor fixed by writing to an organization on the caller's behalf is not available: re-slugging a row the caller did not touch would be a write nobody asked for. The guard is a break-glass invariant (ADR-0135 D5.2). Its update and delete halves already refuse a Default Organization re-slug or delete only when no administrator who can sign in would remain. The insert half asks the same question with the same enumerate, simulate, enumerate machinery, so it cannot drift from them: - `guardOrganizationInsert` is registered on `beforeInsert` of `sys_organization`, priority 20, with the guard's package. - The simulation adds the inserted row to both reads of the Default Organization definition (`PendingStandingWrite.inserted`, as the activation-ledger insert half does). A payload with no id yet is simulated under a placeholder id that no grant can name. - It refuses `PERMISSION_DENIED` / 403 on `sys_organization`. The refusal says what moved and names the remedy: give the current Default Organization the slug `default` first (while it is the only one, that changes nothing else), or grant another account standing. - It admits when an organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a `default`-slugged Default Organization remains. ⛔ It is not standing preservation. A platform administrator who also owns an organization keeps that standing, which the invariant counts, so the insert is admitted and the grant stops anchoring. That is the same verdict the update half gives a re-slug. - Under a wall no grant anchors, so the half returns before any read. **Reach, measured through `os serve` (production, `single`).** Both public create doors already refuse a second organization: better-auth `POST /auth/organization/create` answers `403 ORGANIZATION_CREATION_DISABLED`, and `POST /api/v1/data/sys_organization` answers `405 OBJECT_API_METHOD_NOT_ALLOWED` (that object's allowed list still names `import`, which I did not measure). So the half guards the writers below HTTP: seeds, scripts, a host's own engine calls and imports. ## 2. The ceremony pin The acceptance fixture of `organization-ownership-apply.test.ts` now holds `grant_promoted`: an `admin_full_access` grant with a NULL `organization_id`, in the 17.x shape (by id to an `admin_full_access` mirror row). The test reads `readDefaultOrganizationId` (`@objectstack/core`) over the same SQLite database before the ceremony. It pins that the grant is attributed to that answer (`org_default`), and that under a wall it stays unattributed. Two fixture consequences: - The catalog double now declares `admin_full_access`, as the composed security plugin ships it. - The "name only the uncomposed security plugin declares" block asks for a catalog without it (`catalog({ adminSetDeclared: false })`), which is its premise. The walled test's `sys_permission_set` count moves 3 to 4 (the extra mirror, not deleted under a wall). ## 3. One Default Organization definition `@objectstack/core` exports the rule once, plus the resolver's read: - `findDefaultOrganization(read)`: `slug = 'default'`, else the only organization, else none. The answer reports `several` too. It is parameterised by the read, because the readers differ on purpose in **how** they read, never in what they pick. - `DEFAULT_ORGANIZATION_SLUG`. - `readDefaultOrganizationId(ql)`: the resolver's former private function, now exported. Its read fails loud on an unreachable store and reads a never-provisioned one as none. | copy | now | kept on purpose | |---|---|---| | `resolve-authz-context.ts` (`readDefaultOrganizationId`) | the rule over `tryFind`, exported | the read policy (`AuthzStoreUnavailableError`, registry check) | | `default-organization-invariant.ts` | the rule over a read that **propagates** | the producer half (create, `ambiguous` report) | | `ensure-default-organization.ts` (plugin-auth) | the rule over its swallowing `tryFind` | the walled branch reads only `slug = 'default'`; under a wall no sole-organization leg applies (comment in place) | | `last-admin-guard.ts` | the rule over the guard's own read | the simulation of a pending write (now also an insert) and `scan`'s fail-closed ceiling, which is why it cannot call the resolver's read | | `tenancy-service.ts` (`resolveDefaultOrgId`) | the rule over `findRows` | answers `null`, and a failed read reads as none | | `suggested-audience-bindings.ts` (`resolveDefaultOrganizationId`) | the rule over its read | `scopedSystemCtx()`, and a failed read throws (the promotion decides) | | `organization-ownership-plan.ts` | the rule over raw SQL | no engine; a store with no `slug` column has no `default` to find | Two edge answers move to the resolver's: - In the ceremony's plan, a `default`-slugged row with a blank id now falls through to the only-organization reading. It used to name nothing. - In plugin-security's reader, a numeric id is read as its string. It used to be read as no id. ## Tests and proof All runs are at `6f6e9003` (after merging `main`) unless noted. - **New pins:** - `last-admin-guard.default-organization.test.ts`: 6 cases, 15/15 in the file. - `default-organization.test.ts` (core): 8. - The apply-test pin: the file is 112/112. - **Ablations** (`scripts/ablation-replace.mjs`, each restored to the HEAD blob, `git diff HEAD` empty): - Remove the insert half's `enforceStanding`: 3 pins red. - Remove the wall short-circuit: the walled control red. - Drop the inserted row from the simulation: 3 pins red. - Core: sole leg `=== 1` changed to `>= 1`: 2 red. - The plan naming another organization: the acceptance test red. Its effective-access assertion (`:584`) fails ahead of the new pin, because the same attribution moves that grant's access line. - **Suites:** - At `2c165f8b`: plugin-auth 185 files, 3493 passed. plugin-security 208 files, 4274 passed. core 110 files, 2638 passed. Each part exited 0. - After the merge: the scoped files 292 + 130 + 8 + 112, joined with `&&`, exit 0. `typecheck` of core, plugin-auth, plugin-security and cli: exit 0. - **`node scripts/pm/dispatch-gates.mjs --commands`** at `2c165f8b`: - 76 derived. 74 exit 0 (the three i18n gates after building their prerequisite). - 2 exit 3, NOT MEASURED: `check-plugin-teardown-shape --self-test` needs an unshallow clone; `check:dual-build-cjs-loads` needs the full build. - `--ran`, with exit codes recorded: 76 accounted. `check-changeset-fixed` and `check:optional-error-sink` exit 0. - **`check-governed-merges --branch`:** 0 of 13 paths governed. 633 changed lines (+516/−117). Not Tier H or S. - **R1 (fresh boot):** a production `os serve` of `examples/app-crm` under `single`, on a fresh SQLite file: health 200, sign-up 200, sign-in 200, session 200. The harness's fresh-store leg passes too. - **R4 (walled):** a production `os serve` of `examples/app-crm` under `isolated` and under `group`. `@objectstack/organizations` was declared in the app for the run and reverted after. Both: health 200, sign-up 200, sign-in 200, two organizations created through `POST /auth/organization/create` (200 each, through the new hook's wall path), session 200, 0 organization-less grants. - **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`, both fixture legs, 18.0 and 18.0.0, plus the fresh store): 16/16. - **Full dogfood battery:** running on `6f6e9003` when this body was written. The result is in the os-dev-report on #25208. ## Acceptance notes - **The `admin_full_access` anchor predicate stays in two places.** The guard's `anchorOwnerTest` and the resolver's `grantAnchorsPlatformStanding` each spell it. Record 6253205661 proposed exporting it with the definition. This card's surface is the Default Organization definition, and the guard's form reads the simulated Default Organization, so it is not the same function. - **The `ambiguous.organizationCount` field is always 2.** That is what the boot invariant could measure: the rule reads at most two rows. plugin-auth's boot warning therefore says "holds 2 organizations" for any number above one. This is unchanged and pre-existing. - **Not measured:** whether `import` on `sys_organization` (listed as allowed by the data API under `single`) reaches the engine's `beforeInsert`. --- _Generated by [Claude Code](https://claude.ai/code/session_01ERm1aSqQLiSN87PFmwRfUV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24360dc commit 8dfd126

13 files changed

Lines changed: 516 additions & 117 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
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
9+
10+
Clause-②: yes (narrowing)
11+
Metadata-protocol: none
12+
13+
<!-- 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 narrowing is one more organization write the last-administrator guard refuses at runtime. -->
14+
15+
**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.
16+
17+
- **`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.
18+
- 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.
19+
- 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.
20+
- **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.
21+
22+
**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`.

‎packages/cli/src/utils/organization-ownership-apply.test.ts‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { createHash } from 'node:crypto';
2222
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2323
import { tmpdir } from 'node:os';
2424
import { join } from 'node:path';
25-
import { permissionSetDefinitionFromRow } from '@objectstack/core';
25+
import { permissionSetDefinitionFromRow, readDefaultOrganizationId } from '@objectstack/core';
2626
import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn, hashSpec } from '@objectstack/metadata-core';
2727
import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js';
2828
import {
@@ -83,7 +83,7 @@ function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor {
8383
* Like the registry, it serves a saved definition only from the next boot on
8484
* ({@link boot}), never the moment it is saved.
8585
*/
86-
function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & {
86+
function catalog(opts: { adminSetDeclared?: boolean } = {}): CatalogPort & MetadataDoorPort & TemplatePort & {
8787
saved: Map<string, Record<string, unknown>>;
8888
templates: Map<string, Record<string, unknown>>;
8989
/** Environment `view` saves, in order, with the package each was saved into. */
@@ -92,6 +92,11 @@ function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & {
9292
} {
9393
const declared = new Map<string, Record<string, unknown>>([
9494
['permission|member_default', permissionSetDefinitionFromRow(MEMBER_DEFAULT_ROW)],
95+
// The composed security plugin ships it; `adminSetDeclared: false` is a
96+
// catalog composed without that plugin.
97+
...(opts.adminSetDeclared === false
98+
? []
99+
: [['permission|admin_full_access', permissionSetDefinitionFromRow(ADMIN_FULL_ACCESS_ROW)] as [string, Record<string, unknown>]]),
95100
['position|org_admin', { name: 'org_admin', label: 'Org Admin', delegatable: false, permissionSets: ['member_default'] }],
96101
]);
97102
const saved = new Map<string, Record<string, unknown>>();
@@ -276,6 +281,14 @@ const MEMBER_DEFAULT_ROW = {
276281
system_permissions: '[]',
277282
};
278283

284+
/** The platform set the `single` first-user promotion grants (plugin-security ships it). */
285+
const ADMIN_FULL_ACCESS_ROW = {
286+
name: 'admin_full_access',
287+
label: 'Full Access',
288+
object_permissions: '{}',
289+
system_permissions: '[]',
290+
};
291+
279292
/** A 17.x-shaped database carrying every fate, on the post-stage-8 shape. */
280293
function seed(): void {
281294
db.exec(`
@@ -290,7 +303,8 @@ function seed(): void {
290303
('ps_null', NULL, 'member_default', 'Member', 'platform', 1, '{"crm_deal":{"allowRead":true}}', '[]'),
291304
('ps_org', 'org_default', 'member_default', 'Member', 'package', 1, '{"crm_deal":{"allowRead":true}}', '[]'),
292305
('ps_auth', 'org_default', 'field_auditor', 'Field Auditor', 'admin', 1,
293-
'{"crm_deal":{"allowRead":true,"allowEdit":true}}', '["export_reports"]');
306+
'{"crm_deal":{"allowRead":true,"allowEdit":true}}', '["export_reports"]'),
307+
('ps_admin_mirror', NULL, 'admin_full_access', 'Full Access', 'platform', 1, '{}', '[]');
294308
CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
295309
delegatable INTEGER, active INTEGER);
296310
INSERT INTO sys_position VALUES
@@ -334,7 +348,11 @@ function seed(): void {
334348
('grant_1', 'org_default', 'usr_1', 'ps_org', NULL),
335349
('grant_2', NULL, 'usr_2', 'ps_null', NULL),
336350
('grant_3', 'org_default', 'usr_3', 'ps_auth', NULL),
337-
('grant_4', 'org_default', 'usr_4', 'ps_null', 'member_default');
351+
('grant_4', 'org_default', 'usr_4', 'ps_null', 'member_default'),
352+
-- the first-user promotion's grant as an earlier release wrote it: no
353+
-- organization (ADR-0131 D5 — the ceremony attributes it to the Default
354+
-- Organization, the owner the resolver reads the anchor by)
355+
('grant_promoted', NULL, 'usr_admin', 'ps_admin_mirror', NULL);
338356
CREATE TABLE sys_business_unit (id TEXT PRIMARY KEY, organization_id TEXT, parent_business_unit_id TEXT);
339357
INSERT INTO sys_business_unit VALUES ('bu_b', 'org_b', NULL);
340358
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[] {
397415
return [...out].sort();
398416
}
399417

418+
/**
419+
* The fixture database as an engine answers `find` — the one-argument
420+
* spelling the authorization resolver's reads use — so a reader that takes an
421+
* engine reads these same rows.
422+
*/
423+
const sqliteEngine = () => ({
424+
async find(object: string, query: { where?: Record<string, unknown>; limit?: number }) {
425+
const where = query.where ?? {};
426+
const keys = Object.keys(where);
427+
return db.prepare(
428+
`SELECT * FROM "${object}"${keys.length ? ` WHERE ${keys.map((k) => `"${k}" = ?`).join(' AND ')}` : ''} ORDER BY id LIMIT ${query.limit ?? 100}`,
429+
).all(...keys.map((k) => where[k] as string)) as Record<string, unknown>[];
430+
},
431+
});
432+
400433
/** Effective access AFTER — the v18 reading, on the next boot: every reference is a name, resolved in the catalog. */
401434
async function accessAfter(c: ReturnType<typeof catalog>): Promise<string[]> {
402435
c.boot();
@@ -519,6 +552,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
519552
it('comes through with effective access identical, mirrors gone, owners written, the orphan reported and holding certification, counts reconciling', async () => {
520553
const before = accessBefore();
521554
const countsBefore = counts();
555+
const defaultOrganizationBefore = await readDefaultOrganizationId(sqliteEngine());
522556
const c = catalog();
523557
const l = ledger();
524558

@@ -582,6 +616,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
582616
{ id: 'grant_2', permission_set: 'member_default' },
583617
{ id: 'grant_3', permission_set: 'field_auditor' },
584618
{ id: 'grant_4', permission_set: 'member_default' },
619+
{ id: 'grant_promoted', permission_set: 'admin_full_access' },
585620
]);
586621
expect(rows(`SELECT name FROM pragma_table_info('sys_user_permission_set') WHERE name = 'permission_set_id'`)).toEqual([]);
587622
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', () => {
591626
expect(rows(`SELECT organization_id FROM sys_attachment WHERE id = 'att_1'`)).toEqual([{ organization_id: 'org_b' }]);
592627
expect(rows(`SELECT organization_id FROM crm_deal WHERE id = 'deal_2'`)).toEqual([{ organization_id: 'org_default' }]);
593628
expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_2'`)).toEqual([{ organization_id: 'org_default' }]);
629+
// [ADR-0131 D5] The promotion's organization-less `admin_full_access` grant
630+
// is owned by the organization the resolver reads the anchor by — the
631+
// answer of `readDefaultOrganizationId` (`@objectstack/core`) over this
632+
// same database — so the grant that anchored platform standing before the
633+
// ceremony is the one that anchors it after.
634+
expect(defaultOrganizationBefore).toBe('org_default');
635+
expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_promoted'`))
636+
.toEqual([{ organization_id: defaultOrganizationBefore }]);
594637

595638
// The unattributable row is still present, still NULL — never guessed,
596639
// never deleted — reported by id, and held with its count and remedy.
@@ -922,7 +965,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
922965
expect(rows(`SELECT organization_id FROM sys_file WHERE id = 'file_1'`)).toEqual([{ organization_id: 'org_b' }]);
923966
expect(c.saved.size).toBe(0);
924967
expect(report.steps.find((s) => s.step === 'mirror-deletion')!.status).toBe('not-reached');
925-
expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 3 }]);
968+
expect(rows('SELECT COUNT(*) AS n FROM sys_permission_set')).toEqual([{ n: 4 }]);
969+
// The promotion's organization-less grant is not attributed under a wall.
970+
expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_promoted'`)).toEqual([{ organization_id: null }]);
926971
// The promotion runs under every posture; nothing is deleted before mirror deletion.
927972
expect([...c.templates.keys()].sort()).toEqual(['auth.reset_password|en-US', 'crm.memo|en-US', 'crm.note|en-US', 'crm.renewal|zh-CN']);
928973
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
21022147
('grant_nobypass', 'org_default', 'usr_admin', NULL, 'organization_admin_no_bypass'),
21032148
('grant_unknown', 'org_default', 'usr_9', NULL, 'zz_unknown_set');
21042149
`);
2105-
const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET });
2150+
const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET });
21062151

21072152
const rewrite = step(report, 'rewrite');
21082153
expect(rewrite.status).toBe('blocked');
@@ -2128,7 +2173,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21282173
('grant_admin_legacy', 'org_default', 'usr_admin', 'ps_admin', NULL),
21292174
('grant_gone_legacy', 'org_default', 'usr_9', 'ps_gone', NULL);
21302175
`);
2131-
const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET });
2176+
const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET });
21322177

21332178
const rewrite = step(report, 'rewrite');
21342179
expect(rewrite.status).toBe('blocked');
@@ -2145,7 +2190,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21452190

21462191
it('authored-conversion: the seeded everyone binding gets the composition, never removal; an undeclared position keeps its remedy', async () => {
21472192
db.exec(v17Bindings);
2148-
const report = await apply(catalog(), ledger(), { securityPlugin: NO_SECRET });
2193+
const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin: NO_SECRET });
21492194

21502195
const conversion = step(report, 'authored-conversion');
21512196
expect(conversion.status).toBe('blocked');
@@ -2161,7 +2206,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21612206

21622207
it('under a wall, the rewrite gate: the everyone binding gets the composition; an undeclared position gets the walled remedy', async () => {
21632208
db.exec(v17Bindings);
2164-
const report = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET });
2209+
const report = await apply(catalog({ adminSetDeclared: false }), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET });
21652210

21662211
expect(step(report, 'authored-conversion').status).toBe('done');
21672212
const rewrite = step(report, 'rewrite');
@@ -2181,7 +2226,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21812226
['not said', undefined],
21822227
])('control (%s): authored-conversion keeps the remedy — the composition is the boot\'s answer, never inferred', async (_label, securityPlugin) => {
21832228
db.exec(v17Bindings);
2184-
const conversion = step(await apply(catalog(), ledger(), { securityPlugin }), 'authored-conversion');
2229+
const conversion = step(await apply(catalog({ adminSetDeclared: false }), ledger(), { securityPlugin }), 'authored-conversion');
21852230
expect(blockerOf(conversion, 'position everyone ')).toMatch(/or remove the bindings/);
21862231
expect(conversion.blockers.join('\n')).not.toContain(COMPOSITION);
21872232
});
@@ -2194,7 +2239,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21942239
INSERT INTO sys_user_permission_set VALUES ('grant_admin', 'org_default', 'usr_admin', NULL, 'admin_full_access');
21952240
${v17Bindings}
21962241
`);
2197-
const rewrite = step(await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite');
2242+
const rewrite = step(await apply(catalog({ adminSetDeclared: false }), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite');
21982243
expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/);
21992244
// Not the pointer. This fake catalog declares no `everyone` (a composed boot declares it, sealed), so which
22002245
// walled remedy follows is not this control's question.

‎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

0 commit comments

Comments
 (0)