Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/11979-default-org-admin-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/core": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-auth": minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

- **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.
- **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.
- **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.
- **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).

- **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`.
- **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`.
- **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.
- **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.
21 changes: 21 additions & 0 deletions packages/core/src/security/admin-standing-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,27 @@ const VARIANTS: Record<
},
},

// [ADR-0131 D5] The `single` promotion's grant, owned by the Default
// Organization: the only variant in which that organization's row is read,
// so the union needs it — without it `sys_organization.id` would be absent
// from the declaration and the guard would stop judging the delete that
// moves the anchor off this grant.
'default-organization-owned grant': {
org: 'org_1',
tables: {
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 0 }],
sys_organization: [{ id: 'org_1', slug: 'default' }],
sys_member: [],
sys_user_position: [],
sys_position: [],
sys_position_permission_set: [],
sys_user_permission_set: [
{ id: 'ups_1', user_id: 'usr_1', permission_set: 'admin_full_access', organization_id: 'org_1' },
],
sys_permission_set: [],
},
},

// [#11663 L2] The CONFIG anchor. No grant row anywhere: standing comes from
// the declared administrator list matched against this row's own `email`,
// gated on `email_verified`. This is the only variant in which those two
Expand Down
36 changes: 27 additions & 9 deletions packages/core/src/security/admin-standing-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ export interface AdminStandingTable {
* ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
* authenticates a principal and seeds `permissions` with the key's scopes, and
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
* from the catalog's `admin_full_access` set reached through an UNSCOPED
* `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since
* #11663 L5) or from the deployment config matched against the caller's own
* from the catalog's `admin_full_access` set reached through the anchoring
* `sys_user_permission_set` grant (§6b — the Default Organization's or an
* organization-less one, on a NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own
* STORED `sys_user` row (§6b-config, on every posture), never from a scope
* string and never from the caller-seedable `grants.email`.
*/
Expand All @@ -126,14 +126,17 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
sys_user_permission_set: {
role: 'derives',
reason:
'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an UNSCOPED, '
+ 'in-window (ADR-0091) grant of `admin_full_access` (§6b). Re-pointing it, scoping it to an '
+ 'organization or moving it out of its window revokes the standing while leaving the row in '
+ 'place. ⚠️ Under a WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half '
'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an in-window '
+ '(ADR-0091) grant of `admin_full_access` owned by the Default Organization (ADR-0131 D5, the '
+ 'row the first-user promotion writes) or by no organization (the row it wrote before) — §6, '
+ '`grantAnchorsPlatformStanding`. Re-pointing it, scoping it to another organization or moving '
+ 'it out of its window revokes the standing while leaving the row in place. ⚠️ Under a '
+ 'WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half '
+ 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` '
+ '(§6b-config) and from nothing else, and this row is read only for the permission set it '
+ 'names. Under `single` — the default — the row is unchanged and still the anchor that '
+ "rig's zero-config first-user promotion mints. [ADR-0131 D4] The grant holds its set BY "
+ 'names. Under `single` — the default — the row is still the anchor that rig\'s '
+ 'zero-config first-user promotion mints, owned by the Default Organization (see '
+ '`sys_organization`). [ADR-0131 D4] The grant holds its set BY '
+ 'NAME (`permission_set`): a grant that names nothing confers nothing, and its '
+ '`permission_set_id` is not read here.',
columns: [
Expand All @@ -148,6 +151,21 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
],
},

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

sys_member: {
role: 'derives',
reason:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,69 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg
expect(seen[3]).toBe('MEMBER|');
});
});

/**
* [ADR-0131 D5] The `single` promotion's grant row is OWNED BY THE DEFAULT
* ORGANIZATION (#11979, maintainer ruling A on #18707), so the anchor reads that
* row — one derivation site, the posture its parameter. Before this, the anchor
* read the organization-less row only, so a promoted rig whose grant D10's
* ceremony (or the promotion itself) had given the Default Organization resolved
* a zero-admin deployment.
*/
describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_access grant is the anchor', () => {
const ownedBy = (organizationId: string, organizations: Array<Record<string, unknown>>) => ({
...legacyTables(),
sys_organization: organizations,
sys_user_permission_set: [
{ id: 'ups_1', user_id: 'usr_1', permission_set: ADMIN_FULL_ACCESS, organization_id: organizationId },
],
});
const DEFAULT_ONLY = [{ id: 'org_default', slug: 'default' }];

it('⭐ confers PLATFORM_ADMIN with no config at all — in the Default Organization and with no active organization', async () => {
for (const tenantId of ['org_default', undefined]) {
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_default', DEFAULT_ONLY)), 'usr_1', { nowMs: NOW, tenantId });
expect(grants.posture, String(tenantId)).toBe('PLATFORM_ADMIN');
expect(grants.positions[0]).toBe('platform_admin');
// The anchoring grant applies wherever the principal acts, as the
// organization-less row it replaces did: the set comes with the standing.
expect(grants.permissions).toContain(ADMIN_FULL_ACCESS);
}
});

it('the sole organization is the Default Organization whatever its slug — the boot invariant\'s definition', async () => {
const grants = await resolveUserAuthzGrants(
makeQl(ownedBy('org_only', [{ id: 'org_only', slug: 'acme' }])), 'usr_1', { nowMs: NOW, tenantId: 'org_only' },
);
expect(grants.posture).toBe('PLATFORM_ADMIN');
});

it('⛔ another organization\'s grant of the set confers the set in that organization, never standing', async () => {
const orgs = [{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'b' }];
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_b', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_b' });
expect(grants.permissions).toContain(ADMIN_FULL_ACCESS);
expect(grants.posture).toBe('MEMBER');
expect(grants.positions).not.toContain('platform_admin');
});

it('⛔ several organizations and none of them `default` name no Default Organization, so nothing anchors', async () => {
const orgs = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }];
const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_a', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_a' });
expect(grants.posture).toBe('MEMBER');
});

it('⛔ a WALLED rig derives nothing from the Default Organization\'s grant either, and does not ask which one it is', async () => {
requestPosture('isolated');
const ql = makeQl(ownedBy('org_default', DEFAULT_ONLY));
const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId: 'org_default' });
expect(grants.posture).toBe('MEMBER');
expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization');
});

it('the organization-less row a pre-D5 promotion wrote still anchors, and asks no organization read', async () => {
const ql = makeQl({ ...legacyTables(), sys_organization: DEFAULT_ONLY });
const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW });
expect(grants.posture).toBe('PLATFORM_ADMIN');
expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization');
});
});
Loading
Loading