Skip to content

Commit b2e7870

Browse files
fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it (#24373)
Fixes #11979 Part of #15212 Clause-②: yes (narrowing) C8 stage 8b-0a (plan 6251466182 on #15212). Under the `single` tenancy posture the first-user platform-admin promotion keeps its zero-config behaviour, and the `admin_full_access` grant row it writes is now **owned by the Default Organization** (ADR-0131 D5, maintainer ruling A on #18707). Every reader of platform standing reads that row beside the organization-less one. The walled posture is unchanged. ## What changes 1. **The promotion writes the Default Organization** (`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`). The grant carries `organization_id` = the Default Organization: `slug = 'default'`, else the only organization. That is plugin-auth's boot-invariant definition, resolved through the module's existing `resolveDefaultOrganizationId`. A deployment that names no Default Organization (several organizations, none `default`) is not promoted. It logs one `error` line and returns `reason: 'default_organization_unresolved'`, and writes no grant without an owner. The holder scan (leg A, and the predicate legs B and C apply) reads the Default Organization's grant and the organization-less one. A user holding both is one holder, and whichever row sorts first by `id` answers. 2. **The resolver's anchor is one predicate with the posture as its parameter** (`packages/core/src/security/resolve-authz-context.ts`). `grantAnchorsPlatformStanding(grant, { walled, defaultOrganizationId })`: under a wall nothing anchors (unchanged); under `single` the Default Organization's grant and the organization-less row anchor. The anchoring grant applies in whichever organization the principal acts in, as the organization-less row always did. Any other organization's grant of the set confers the set, never standing. `sys_organization` is read only for a principal holding an organization-owned `admin_full_access` grant under `single`, so the batch-equivalence goldens do not move. 3. **plugin-auth reads the same row** (`ensure-default-organization.ts`). The grant anchor reads the Default Organization's grant. Under `single`, step 4 reuses the Default Organization by the invariant's definition (a sole organization with another slug included) instead of minting a second, `default`-slugged one that would take the anchor off the promoted grant. Under a wall only the `default`-slugged one is reused, as before. 4. **The extra `sys_organization` reads, and when they run.** The resolver reads it only under `single`, and only for a principal holding an organization-owned `admin_full_access` grant: one small indexed read when a `default`-slugged organization exists, two otherwise, per uncached request (deduplicated within a request; the cross-request grants cache is off by default). Every other principal reads nothing more, which is why the batch-equivalence goldens (whose fixture holds the organization-less row) do not move. The last-administrator guard adds one or two reads per enumeration whenever an `admin_full_access` grant names an owner, so about three to six small reads per guarded write on `sys_user`, `sys_member`, `sys_user_permission_set`, the activation ledger and `sys_organization`. ### Landing points outside the claim's file surface, and why - `packages/plugins/plugin-auth/src/last-admin-guard.ts` is the break-glass guard. Its enumeration, its "emptied rather than fresh" evidence reads and its switched-off-set read all keyed on `!organization_id`. Left alone, the promoted admin's grant would count as no administrator, and the guard would wave through the ban or revocation that ends the last one. It now reads the anchor the resolver reads. Because the anchor now depends on `sys_organization`, it also judges the two organization writes that move it: changing the Default Organization's `slug` while another organization exists, and deleting it. - `packages/core/src/security/admin-standing-surface.ts` gains `sys_organization` as a `derives` table (columns `id`, `slug`, measured by `admin-standing-surface.test.ts`). The f8eb736 correspondence gate turned red without it, and is green with the guard's new standing-key list. - No ceremony file is touched. A3 below shows the ceremony's attribution is the intended D10 fate. The fix is on the readers' side. ## A3 — measured: the ceremony attributes the promoted admin's grant to the Default Organization On `18.0.0-next.3` semantics (this branch's base `e4bcced0`), with the cli acceptance fixture of `organization-ownership-apply.test.ts` plus one promoted-admin grant (`grant_admin`, `organization_id` NULL, `permission_set` `admin_full_access`). This was a scratch copy of the fixture run under `--project integration` and deleted afterwards: ``` A3-READING [["attribution","done"], …] [{"id":"grant_admin","user_id":"usr_admin","permission_set":"admin_full_access","organization_id":"org_default"}] ``` So after `os migrate organization-ownership --apply` the grant is the Default Organization's. On the base tree that row conferred nothing. The resolver read `!ownGrant` only: the new `[ADR-0131 D5]` resolver tests fail on base (2 of 6). And the holder scan found no NULL holder and promoted again: the new fresh-boot golden "an upgraded database whose administrator's grant the ceremony gave the Default Organization promotes nobody else" fails on base and passes here. That is the live defect the dispatch suspected. The `rewrite` step blocked in that scratch run only because the fixture's catalog double serves no `admin_full_access`, which has nothing to do with attribution. ## Tests On the final head `bc90a53af6` (after merging `origin/main`): - `pnpm --filter @objectstack/core test`: 104 files, 2558 passed. - `pnpm --filter @objectstack/plugin-security test`: 188 files, 3739 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-auth test`: 162 files, 3140 passed, 10 skipped. - `typecheck` for core, plugin-security and plugin-auth: exit 0 (plugin-auth after its build, which its examples project reads). - Platform-admin dogfood (`packages/qa/dogfood`, on a full build): `admin-platform-admin-standing`, `org-admin-affordance-reach` and `admin-credential-lifecycle` pass, 26 tests. The boot log shows `first user promoted to platform admin: admin@objectos.ai` three times and no withheld promotion. New or flipped pins assert the substance: - the fresh-boot golden's grant owner `null` becomes `org_default`; - an attributed grant is a holder, and nothing is re-promoted; - a database naming no Default Organization withholds at `error` and writes nothing; - the resolver confers standing from the Default Organization's grant, in it and with no active organization, and from a sole organization of any slug, but not from another organization's grant, not with several organizations and none `default`, and never under a wall, which reads no organization; - the guard counts the holder and refuses the ban, the revocation, the re-scoping, the re-slugging and the delete; the controls are another organization's grant and a sole organization's slug or name change; - `ensureDefaultOrganization` binds the Default Organization's grant holder. The test fixtures that promote now hold the Default Organization the `single` boot creates. **Reverse verification.** The fix was committed first. The four source files were then restored to base `e4bcced0` and the new tests run, and the files restored to HEAD (proved by an empty `git diff HEAD` and HEAD-blob hash equality). Results on base: core D5 suite 2 failed; plugin-security fresh-boot golden 3 failed; plugin-auth guard and default-org suites 7 failed. All green on HEAD. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 73 families at `11016ab9`. All 73 ran with exit 0 after a full build (`check:i18n` and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET before it, and green after). `--ran` reconciliation: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero)". The derivation is unchanged on the final head. A subset (`check:published-files`, `check:cross-package-test-inputs`, `check:durability-log-level`, `check:startup-registry-verdict`, `check:issue-citations`, `check:doc-authoring`) was re-run after the last source commit, all exit 0. Every family CI runs is read from this PR's check-runs. ## Acceptance notes - Clause-②: yes (narrowing). Breaking, shipped as `minor` on all three packages under the launch-window convention; the changeset carries the three FROM → TO pairs with their fixes and the ADR-0087 disposition `not-required (no-migration-prescription)`. Narrowings: (1) plugin-security — a `single` deployment with several organizations and none slugged `default` is no longer promoted (one `error` line, `reason: 'default_organization_unresolved'`, no row); fix: slug the intended organization `default`. (2) plugin-auth — a `sys_organization` `slug` change or delete that would leave no administrator who can sign in is refused (403 `PERMISSION_DENIED`); fix: give another account administrator standing first. (3) plugin-auth — `ensureDefaultOrganization` under `single` reuses a sole organization of any slug instead of creating a second `default`-slugged one. Widening: under `single` a Default-Organization-owned `admin_full_access` grant now confers `PLATFORM_ADMIN` and applies in every organization (a pre-existing Default-Organization-scoped grant gains the rung on upgrade), and `sys_organization` becomes a standing-deriving table. No spec, REST answer or package entry changes: the new core helpers and the guard's organization key list are module-private; the bootstrap's return gains one `reason` string. - Line budget: the dispatch estimated about 200 source lines. This lands about 620 source lines and 425 test lines. The extra source is the break-glass guard (enumeration, evidence reads, organization hooks), which the anchor change made necessary, plus its explanatory comments. - Not in this PR: the organization-less reader's retirement (stage 8c-2) and engine-wide system-insert stamping (stage 8b). The organization-less row still anchors under `single`, by D5's "until D10's migration clears it". - #11979's Constraints paragraph ("config now required for admin") is superseded by ruling A, as the card's discharged note says. Nothing here requires config under `single`. --- _Generated by [Claude Code](https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d65826f commit b2e7870

23 files changed

Lines changed: 918 additions & 156 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-auth": minor
5+
---
6+
7+
fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, export, option, error code or stored shape is added, removed or renamed: the new resolver and guard helpers and the guard's organization standing-key list are module-private, and `bootstrapPlatformAdmin`'s `reason` was already a string. The only stored-shape move — an organization-less `admin_full_access` grant becoming the Default Organization's — is ADR-0131 D10's ceremony (`os migrate organization-ownership --apply`), so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no Zod schema in packages/spec changes (not runtime-interface-only or type-surface-only in the spec sense); and nothing exported is removed (not runtime-export-retired). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes.
16+
- **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change.
17+
- **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly.
18+
- **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write).
19+
20+
- **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`.
21+
- **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`.
22+
- **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder.
23+
- **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization.

‎packages/core/src/security/admin-standing-surface.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,27 @@ const VARIANTS: Record<
268268
},
269269
},
270270

271+
// [ADR-0131 D5] The `single` promotion's grant, owned by the Default
272+
// Organization: the only variant in which that organization's row is read,
273+
// so the union needs it — without it `sys_organization.id` would be absent
274+
// from the declaration and the guard would stop judging the delete that
275+
// moves the anchor off this grant.
276+
'default-organization-owned grant': {
277+
org: 'org_1',
278+
tables: {
279+
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 0 }],
280+
sys_organization: [{ id: 'org_1', slug: 'default' }],
281+
sys_member: [],
282+
sys_user_position: [],
283+
sys_position: [],
284+
sys_position_permission_set: [],
285+
sys_user_permission_set: [
286+
{ id: 'ups_1', user_id: 'usr_1', permission_set: 'admin_full_access', organization_id: 'org_1' },
287+
],
288+
sys_permission_set: [],
289+
},
290+
},
291+
271292
// [#11663 L2] The CONFIG anchor. No grant row anywhere: standing comes from
272293
// the declared administrator list matched against this row's own `email`,
273294
// gated on `email_verified`. This is the only variant in which those two

‎packages/core/src/security/admin-standing-surface.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ export interface AdminStandingTable {
9898
* ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
9999
* authenticates a principal and seeds `permissions` with the key's scopes, and
100100
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
101-
* from the catalog's `admin_full_access` set reached through an UNSCOPED
102-
* `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since
103-
* #11663 L5) or from the deployment config matched against the caller's own
101+
* from the catalog's `admin_full_access` set reached through the anchoring
102+
* `sys_user_permission_set` grant (§6b — the Default Organization's or an
103+
* organization-less one, on a NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own
104104
* STORED `sys_user` row (§6b-config, on every posture), never from a scope
105105
* string and never from the caller-seedable `grants.email`.
106106
*/
@@ -126,14 +126,17 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
126126
sys_user_permission_set: {
127127
role: 'derives',
128128
reason:
129-
'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an UNSCOPED, '
130-
+ 'in-window (ADR-0091) grant of `admin_full_access` (§6b). Re-pointing it, scoping it to an '
131-
+ 'organization or moving it out of its window revokes the standing while leaving the row in '
132-
+ 'place. ⚠️ Under a WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half '
129+
'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an in-window '
130+
+ '(ADR-0091) grant of `admin_full_access` owned by the Default Organization (ADR-0131 D5, the '
131+
+ 'row the first-user promotion writes) or by no organization (the row it wrote before) — §6, '
132+
+ '`grantAnchorsPlatformStanding`. Re-pointing it, scoping it to another organization or moving '
133+
+ 'it out of its window revokes the standing while leaving the row in place. ⚠️ Under a '
134+
+ 'WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half '
133135
+ 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` '
134136
+ '(§6b-config) and from nothing else, and this row is read only for the permission set it '
135-
+ 'names. Under `single` — the default — the row is unchanged and still the anchor that '
136-
+ "rig's zero-config first-user promotion mints. [ADR-0131 D4] The grant holds its set BY "
137+
+ 'names. Under `single` — the default — the row is still the anchor that rig\'s '
138+
+ 'zero-config first-user promotion mints, owned by the Default Organization (see '
139+
+ '`sys_organization`). [ADR-0131 D4] The grant holds its set BY '
137140
+ 'NAME (`permission_set`): a grant that names nothing confers nothing, and its '
138141
+ '`permission_set_id` is not read here.',
139142
columns: [
@@ -148,6 +151,21 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
148151
],
149152
},
150153

154+
sys_organization: {
155+
role: 'derives',
156+
reason:
157+
'[ADR-0131 D5] Names the Default Organization — `slug = \'default\'`, else the only '
158+
+ 'organization (`readDefaultOrganizationId`) — whose `admin_full_access` grant anchors '
159+
+ 'platform standing under `single` (§6). Read only for a principal holding an '
160+
+ 'organization-owned grant of that set, and never under a wall. Re-slugging the Default '
161+
+ 'Organization while several organizations exist, or deleting it, moves the anchor off its '
162+
+ 'grant and revokes that standing with no grant row touched.',
163+
columns: [
164+
'id',
165+
'slug',
166+
],
167+
},
168+
151169
sys_member: {
152170
role: 'derives',
153171
reason:

‎packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,3 +655,69 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg
655655
expect(seen[3]).toBe('MEMBER|');
656656
});
657657
});
658+
659+
/**
660+
* [ADR-0131 D5] The `single` promotion's grant row is OWNED BY THE DEFAULT
661+
* ORGANIZATION (#11979, maintainer ruling A on #18707), so the anchor reads that
662+
* row — one derivation site, the posture its parameter. Before this, the anchor
663+
* read the organization-less row only, so a promoted rig whose grant D10's
664+
* ceremony (or the promotion itself) had given the Default Organization resolved
665+
* a zero-admin deployment.
666+
*/
667+
describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_access grant is the anchor', () => {
668+
const ownedBy = (organizationId: string, organizations: Array<Record<string, unknown>>) => ({
669+
...legacyTables(),
670+
sys_organization: organizations,
671+
sys_user_permission_set: [
672+
{ id: 'ups_1', user_id: 'usr_1', permission_set: ADMIN_FULL_ACCESS, organization_id: organizationId },
673+
],
674+
});
675+
const DEFAULT_ONLY = [{ id: 'org_default', slug: 'default' }];
676+
677+
it('⭐ confers PLATFORM_ADMIN with no config at all — in the Default Organization and with no active organization', async () => {
678+
for (const tenantId of ['org_default', undefined]) {
679+
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_default', DEFAULT_ONLY)), 'usr_1', { nowMs: NOW, tenantId });
680+
expect(grants.posture, String(tenantId)).toBe('PLATFORM_ADMIN');
681+
expect(grants.positions[0]).toBe('platform_admin');
682+
// The anchoring grant applies wherever the principal acts, as the
683+
// organization-less row it replaces did: the set comes with the standing.
684+
expect(grants.permissions).toContain(ADMIN_FULL_ACCESS);
685+
}
686+
});
687+
688+
it('the sole organization is the Default Organization whatever its slug — the boot invariant\'s definition', async () => {
689+
const grants = await resolveUserAuthzGrants(
690+
makeQl(ownedBy('org_only', [{ id: 'org_only', slug: 'acme' }])), 'usr_1', { nowMs: NOW, tenantId: 'org_only' },
691+
);
692+
expect(grants.posture).toBe('PLATFORM_ADMIN');
693+
});
694+
695+
it('⛔ another organization\'s grant of the set confers the set in that organization, never standing', async () => {
696+
const orgs = [{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'b' }];
697+
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_b', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_b' });
698+
expect(grants.permissions).toContain(ADMIN_FULL_ACCESS);
699+
expect(grants.posture).toBe('MEMBER');
700+
expect(grants.positions).not.toContain('platform_admin');
701+
});
702+
703+
it('⛔ several organizations and none of them `default` name no Default Organization, so nothing anchors', async () => {
704+
const orgs = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }];
705+
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_a', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_a' });
706+
expect(grants.posture).toBe('MEMBER');
707+
});
708+
709+
it('⛔ a WALLED rig derives nothing from the Default Organization\'s grant either, and does not ask which one it is', async () => {
710+
requestPosture('isolated');
711+
const ql = makeQl(ownedBy('org_default', DEFAULT_ONLY));
712+
const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId: 'org_default' });
713+
expect(grants.posture).toBe('MEMBER');
714+
expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization');
715+
});
716+
717+
it('the organization-less row a pre-D5 promotion wrote still anchors, and asks no organization read', async () => {
718+
const ql = makeQl({ ...legacyTables(), sys_organization: DEFAULT_ONLY });
719+
const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW });
720+
expect(grants.posture).toBe('PLATFORM_ADMIN');
721+
expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization');
722+
});
723+
});

0 commit comments

Comments
 (0)