Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/18336-retire-walled-legacy-platform-admin-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/core": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-auth": minor
"@objectstack/runtime": minor
"@objectstack/plugin-hono-server": minor
"@objectstack/organizations": minor
---

**Breaking (shipped as `minor` under the launch-window convention).** Under a **walled** tenancy posture (`group` / `isolated`), a legacy unscoped `admin_full_access` grant row no longer confers `PLATFORM_ADMIN`; platform standing there is derived from `OS_PLATFORM_OWNER_EMAIL` and from nothing else. The migration pointer that announced this since 17.3.0 is retired with it: `reportLegacyPlatformAdminGrant` and `resetLegacyPlatformAdminGrantReport` are **removed from `@objectstack/core`'s published entry** (#18336, #11663 leg L5).

⚠️ **The `single` posture is untouched, deliberately.** Its zero-config first-user promotion still mints that row and that row still confers `PLATFORM_ADMIN` — a development environment started for a moment cannot be asked to declare an administrator first. Choice 4A (#11974) rules that promotion correct, and the maintainer's 2026-09-08 ruling on #16682 is verbatim: 「retiring the walled write must not retire the `single` one」. The `single` half's disposition is #11979's. ADR-0131 D5, as amended 2026-09-17 (#18413), is the governing record.

**What a walled deployment must do.** Declare each administrator's **verified** address in `OS_PLATFORM_OWNER_EMAIL` (comma-separated for several) before upgrading. A walled rig that upgrades with the variable undeclared and an unscoped grant row still in place has **zero** platform administrators; the bootstrap now says so **at error**, naming the variable, the row and its holder — L4 used to skip that line for exactly this rig, on the ground that the deprecation pointer carried the remedy instead, and both halves of that arrangement have now expired.

- **17.3.0 opened the window, this closes it.** L4 (17.3.0) stopped the walled bootstrap from ever *writing* the row and started the once-per-process pointer; L5 stops the walled derivation from *reading* it. The window was time-boxed and loud by design (#11663 P5).
- **The retirement takes the ANCHOR, not the ROW.** Nothing here writes, deletes or re-owns any grant row — a walled holder keeps the `admin_full_access` permission set they hold, and loses only platform-admin *standing*: the rung and the built-in `platform_admin` position. That row's ownership is ADR-0131 C3's, on the v18 line.
- **No new query.** The posture gate reads the environment, never the engine, so the recorded query multiset is identical under both of its answers — measured, not asserted. Under a wall the guard's grade-1 scan is skipped outright, so that path issues one read fewer.
- **`@objectstack/plugin-auth` moves with it, at TWO readers.** `ensureDefaultOrganization`'s step-2 legacy fallback is keyed on the same expression: under a wall it no longer answers「which user is the platform admin?」from the oldest unscoped grant, so the account it would have bound as the Default Organization's `owner` — and handed the org's seeded rows to — is no longer selected. ⛔ That reader does not merely count the population, it **confers** on it, which is why it is keyed here rather than sequenced. Its bootstrap-trigger predicate retires the matching `sys_user_permission_set`-insert arm under a wall with it (cost only; the `sys_user` arms are untouched, and on a walled rig the declared owner's verifying update is the only write that ever grows the population). And:
- **`@objectstack/plugin-auth`'s break-glass guard moves with it.** `last-admin-guard.ts` enumerates the administrator population from the SAME anchor, and its contract is to answer the same question the derivation answers. Its grade-1 (grant-anchored) enumeration is now keyed on the identical expression, so under a wall the guard no longer counts a holder the derivation does not recognise. Consequence on a walled rig: a write that would end the last **config**-anchored administrator's standing is now REFUSED where it was permitted, and a write that removes the now-inert grant row is no longer refused as though it removed the last administrator. Under `single` the guard is unchanged. Its two zero-population refusals also gained a walled clause, because「restore the `admin_full_access` row」stopped being a remedy that ends the emptiness there.
- **`@objectstack/organizations`' walled bootstrap moves with it.** That package wraps `ensureDefaultOrganization` and is the runtime that actually performs the default-organization bootstrap on a walled deployment (plugin-auth's own wiring skips it there). With the helper's legacy fallback keyed off, a walled rig carrying a legacy grant row **no longer** has a Default Organization created for that holder, and that holder is no longer bound as its `owner`; the bootstrap waits for a declared administrator to verify instead. ⚠️ Named because the behaviour an operator gets **from this package** moves — its own source does not change, and the pin re-authored inside it is not the reason.
- **Why `@objectstack/runtime` and `@objectstack/plugin-hono-server` are named.** Neither package's own source changes. Both carry `export * from '@objectstack/core'` (`runtime/src/index.ts`, `plugin-hono-server/src/adapter.ts`) and their built `.d.ts` carry that statement, so the two removed names leave their published surfaces too. All publishable packages sit in one Changesets `fixed` group, so naming them moves no version — it is named so the tombstone reaches the CHANGELOG an upgrading consumer of THOSE packages greps. Precedent is mixed (a core-only declaration exists); this follows the `ApiRegistry` precedent, which named every package the removal reached.

<!-- adr-0087: not-required (no-migration-prescription) Nothing here is a metadata surface: the two removed symbols are plain runtime functions in `packages/core/src/security/platform-admin.ts` with no Zod schema, no `packages/spec` declaration and no stored representation, and the behaviour change is an authorization derivation keyed on an environment variable. `objectstack migrate meta` therefore has nothing to rewrite — the channels that reach an affected consumer are the compiler (for the removed exports) and the boot-time fail-closed log line (for the walled standing). No grant row is written, deleted or re-owned by this change; that rows own migration is ADR-0131 D10/C3 and stays on the v18 line. The plugin-auth guard change and the two re-export packages add no metadata surface either. -->
18 changes: 12 additions & 6 deletions packages/core/src/security/admin-standing-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ export interface AdminStandingTable {
* authenticates a principal and seeds `permissions` with the key's scopes, and
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
* from a `sys_permission_set` row reached through an UNSCOPED
* `sys_user_permission_set` grant (§6b) or from the deployment config matched
* against the caller's own STORED `sys_user` row (§6b-config), never from a
* scope string and never from the caller-seedable `grants.email`.
* `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
* STORED `sys_user` row (§6b-config, on every posture), never from a scope
* string and never from the caller-seedable `grants.email`.
*/
export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>> = {
sys_permission_set: {
Expand Down Expand Up @@ -130,9 +131,14 @@ 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: an UNSCOPED, in-window (ADR-0091) grant of '
+ '`admin_full_access` (§6). Re-pointing it, scoping it to an organization or moving it out '
+ 'of its window revokes the standing while leaving the row in place.',
'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 '
+ '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.",
columns: [
'user_id',
'permission_set_id',
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ export {
// (plugin-auth's break-glass guard, plugin-security's bootstrap, the audit
// surface) ask the SAME question instead of re-implementing the parse — which
// is the whole reason the config read has exactly one home.
//
// [#11663 L5] ⛔ `reportLegacyPlatformAdminGrant` and
// `resetLegacyPlatformAdminGrantReport` are NO LONGER exported — they are gone.
// They carried L4's time-boxed migration notice, and L5 is that window's exit.
export {
PLATFORM_ADMIN_EMAIL_SEPARATOR,
normalizePlatformAdminEmail,
Expand All @@ -221,8 +225,6 @@ export {
resetPlatformAdminEmailMemo,
matchesConfiguredPlatformAdmin,
isConfiguredPlatformAdminEmail,
reportLegacyPlatformAdminGrant,
resetLegacyPlatformAdminGrantReport,
setPlatformAdminConfigSink,
type PlatformAdminEmailConfig,
type PlatformAdminConfigSink,
Expand Down
94 changes: 42 additions & 52 deletions packages/core/src/security/platform-admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
* row it has no business looking at.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import {
isConfiguredPlatformAdminEmail,
matchesConfiguredPlatformAdmin,
normalizePlatformAdminEmail,
parsePlatformAdminEmails,
reportLegacyPlatformAdminGrant,
resetLegacyPlatformAdminGrantReport,
resetPlatformAdminEmailMemo,
resolvePlatformAdminEmails,
setPlatformAdminConfigSink,
Expand All @@ -41,7 +39,6 @@ beforeEach(() => {
ambient = process.env[ENV];
delete process.env[ENV];
resetPlatformAdminEmailMemo();
resetLegacyPlatformAdminGrantReport();
sink = makeSink();
setPlatformAdminConfigSink(sink);
});
Expand All @@ -50,7 +47,6 @@ afterEach(() => {
if (ambient === undefined) delete process.env[ENV];
else process.env[ENV] = ambient;
resetPlatformAdminEmailMemo();
resetLegacyPlatformAdminGrantReport();
setPlatformAdminConfigSink(undefined);
});

Expand Down Expand Up @@ -244,55 +240,49 @@ describe('matchesConfiguredPlatformAdmin — verified match only, fail closed',
});
});

describe('[#11663 P5] reportLegacyPlatformAdminGrant', () => {
it('names the holder, the variable and the line to add — once per process', () => {
reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'Ada@Example.com' });
reportLegacyPlatformAdminGrant({ userId: 'usr_2', email: 'bob@example.com' });
expect(sink.warns).toHaveLength(1);
expect(sink.warns[0]).toContain('usr_1');
expect(sink.warns[0]).toContain(`${ENV}=ada@example.com`);
expect(sink.warns[0]).toContain('admin_full_access');
});

it('falls back to a placeholder when the row was never loaded', () => {
// The notice must never force a `sys_user` read of its own — see the
// §6b-config branch, which passes the memoized row only if it is already
// there. A fully-seeded API-key principal has no row loaded.
reportLegacyPlatformAdminGrant({ userId: 'usr_1' });
expect(sink.warns[0]).toContain(`${ENV}=<the administrator's verified email address>`);
/**
* [#11663 L5] The migration pointer is RETIRED — an ABSENCE pin, because absence
* is the only thing left to assert about it.
*
* L4 opened a time-boxed, loud migration window and this module carried its
* pointer: `reportLegacyPlatformAdminGrant`, latched once per process, plus
* `resetLegacyPlatformAdminGrantReport` to drop the latch for tests. L5 closed
* the window — under a walled posture the row the pointer pointed away from is
* no longer an anchor at all (`resolve-authz-context.ts` §6b), and under
* `single` the pointer never fired (it was posture-keyed by #13667). Both
* symbols are gone, from this module AND from `@objectstack/core`'s published
* entry.
*
* ⛔ This pin is not decoration. The suites that USED to cover these symbols all
* asserted a message or a latch count; deleting them leaves nothing that notices
* a well-meaning later edit re-adding a "helpful" deprecation warn to the
* authorization path — which is a per-request cost on every walled rig and a
* second, silent dual-track of the very kind the window existed to close. If a
* future leg genuinely needs a pointer again, ⛔ do not re-add it here: the
* operator-facing line belongs at BOOT, in
* `plugin-security/src/bootstrap-platform-admin.ts`, where an operator can act
* on it. Re-authoring this pin is then a deliberate act, which is the point.
*/
describe('[#11663 L5] the legacy-grant deprecation pointer is retired', () => {
it('⛔ neither symbol is exported by this module any more', async () => {
const mod: Record<string, unknown> = await import('./platform-admin.js');
expect('reportLegacyPlatformAdminGrant' in mod).toBe(false);
expect('resetLegacyPlatformAdminGrantReport' in mod).toBe(false);
// Positive control — the same probe on the same module object finds the
// symbols this leg KEEPS, so an absence read here is an absence and not a
// module that failed to load.
expect('matchesConfiguredPlatformAdmin' in mod).toBe(true);
expect('resolvePlatformAdminEmails' in mod).toBe(true);
});

it('⭐ [#11975] the latch is PER PROCESS, never persistent — a fresh instance re-announces', async () => {
// The migration window's loudness discipline (#11975), in the half nothing
// else pins. Every sibling pin asserts SUPPRESSION — this file's
// "once per process" above, and plugin-security's boot-side
// `bootstrap-platform-admin-walled-owner.test.ts` "EXACTLY ONE line ... even
// across repeated bootstraps" — so all of them stay GREEN if the latch is
// ever hardened past the process: moved onto `globalThis`, stored in a
// module the host keeps alive, or persisted to a row or file to quiet the
// noise. Any of those turns "once per process" into "once EVER", and a
// deployment restarted onto the old anchor is then never told again, which
// is the silent dual-track the window exists to prevent.
//
// A fresh module registry is the closest in-process stand-in for a boot: it
// re-instantiates the module scope the latch lives in exactly as a new
// process would. ⛔ Do not "fix" a red here by resetting the latch in the
// loop — the point is that nothing had to.
const linesPerBoot: number[] = [];
for (let boot = 0; boot < 3; boot++) {
vi.resetModules();
const fresh = await import('./platform-admin.js');
const bootSink = makeSink();
fresh.setPlatformAdminConfigSink(bootSink);
// Twice, so a per-call regression is caught by the same assertion.
fresh.reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'ada@example.com' });
fresh.reportLegacyPlatformAdminGrant({ userId: 'usr_1', email: 'ada@example.com' });
fresh.setPlatformAdminConfigSink(undefined);
linesPerBoot.push(bootSink.warns.length);
}
// Exactly one per boot: not 0 (a latch that outlived the process) and not 2
// (a pointer that became per-call).
expect(linesPerBoot).toEqual([1, 1, 1]);
it("⛔ nor by `@objectstack/core`'s security entry — the published surface lost them", async () => {
const entry: Record<string, unknown> = await import('./index.js');
expect('reportLegacyPlatformAdminGrant' in entry).toBe(false);
expect('resetLegacyPlatformAdminGrantReport' in entry).toBe(false);
// Positive control on the same entry: its sibling config exports are still
// re-exported, so the two absences above are about these two names.
expect('matchesConfiguredPlatformAdmin' in entry).toBe(true);
expect('setPlatformAdminConfigSink' in entry).toBe(true);
});
});

Expand Down
52 changes: 15 additions & 37 deletions packages/core/src/security/platform-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,43 +331,21 @@ export function isConfiguredPlatformAdminEmail(
}

/**
* [#11663 P5] The migration pointer for the LEGACY anchor.
* [#11663 L5] ⛔ The migration pointer for the LEGACY anchor is GONE, and with it
* `reportLegacyPlatformAdminGrant` / `resetLegacyPlatformAdminGrantReport`.
*
* Nothing is revoked in this leg: an unscoped, in-window `admin_full_access`
* grant still confers `PLATFORM_ADMIN` exactly as it did (design §5 step 3 —
* config-derived standing is ADDED, which is what makes this safe to land ahead
* of every deployment setting the variable). What changes is that the row is now
* the OLD anchor, so a holder whose standing rests on it alone is told, once,
* which config line re-anchors them before the row route is removed.
* L4 opened a time-boxed, loud migration window: a walled rig whose platform
* standing rested on the unscoped `admin_full_access` grant row alone was told,
* once per process, which configuration line re-anchors it. L5 is that window's
* EXIT — on a walled rig the row is no longer an anchor at all
* (`resolve-authz-context.ts` §6b), so there is nothing left to point away from.
*
* Once per process, naming ONE holder. Deliberately not once per holder: this
* runs inside the authorization path, and an unbounded per-user ledger there is
* a memory surface for something whose whole job is to say "go look at the
* configuration". The population question (who ALL the administrators are) is
* the audit surface's, filed as its own leg.
* ⛔ Do not reintroduce a request-path notice for this. The walled rig that has
* declared nobody is told at BOOT, by the fail-closed backstop in
* `plugin-security/src/bootstrap-platform-admin.ts`, where an operator can act on
* it — not once per process from inside the authorization path.
*
* ⚠️ The `single` posture is NOT affected: its first-user promotion and its grant
* row stand (Choice 4A), and that row is still that rig's anchor. Its disposition
* is #11979's. See ADR-0131 D5, as amended 2026-09-17 (#18413).
*/
let legacyGrantPointerSaid = false;

export function reportLegacyPlatformAdminGrant(input: {
userId: string;
email?: unknown;
}): void {
if (legacyGrantPointerSaid) return;
legacyGrantPointerSaid = true;
const email = normalizePlatformAdminEmail(input.email);
sink.warn(
`[authz] user ${input.userId} holds PLATFORM_ADMIN through the legacy unscoped `
+ `'admin_full_access' grant row, not through ${PLATFORM_OWNER_EMAIL_ENV}. The grant row is `
+ 'the OLD anchor and is honoured for now; it is removed in a later release. Re-anchor this '
+ `deployment by declaring its administrators in configuration: ${PLATFORM_OWNER_EMAIL_ENV}=`
+ `${email || '<the administrator\'s verified email address>'}`
+ ' (comma-separated for several), and make sure each account\'s email is VERIFIED — an '
+ 'unverified account holding a configured address is not an administrator. Reported once '
+ 'per process; further holders are not listed.',
);
}

/** Drop the once-per-process latch — for tests. */
export function resetLegacyPlatformAdminGrantReport(): void {
legacyGrantPointerSaid = false;
}
Loading
Loading