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
16 changes: 16 additions & 0 deletions .changeset/platform-admin-config-anchor.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/core': minor
'@objectstack/plugin-auth': minor
---

`PLATFORM_ADMIN` can now be anchored on deployment CONFIGURATION instead of a stored grant row: an account whose `sys_user.email` is on `OS_PLATFORM_OWNER_EMAIL` **and** whose `email_verified` reads verified resolves `PLATFORM_ADMIN` with the declared `admin_full_access` capability set, derived live on each authorization resolution (#11663 leg L2, design accepted 2026-08-25 as bundle 1A/2B/3A/4A/5A/6A/7A).

**Additive — nothing is revoked.** The legacy unscoped `admin_full_access` grant still confers exactly as it did; a holder whose standing rests on the row alone now gets a once-per-process pointer at the configuration line that re-anchors them. A deployment that has declared no administrators resolves byte-identically to before: the config list is empty, the derivation answers "not an admin" before it reads any row, and the pinned batch-equivalence query multiset is unchanged.

**The variable takes a list.** `OS_PLATFORM_OWNER_EMAIL` accepts one address or a comma-separated list of them — one normalization (`trim().toLowerCase()`), duplicates collapsed, blank entries dropped. ⛔ Any entry that is not an address **fails the whole variable closed** with a loud refusal naming it, rather than being skipped: silently dropping a typo would leave a narrower administrator set than the operator declared, with nothing anywhere to notice. Unset, blank or refused all mean **zero** config-derived administrators.

**Verified-email match only.** An unverified account holding a configured address confers nothing, and an ABSENT `email_verified` column reads unverified. The match reads the caller's own **stored** `sys_user` row, never the caller-supplied session email.

New exports from `@objectstack/core`: `resolvePlatformAdminEmails`, `parsePlatformAdminEmails`, `matchesConfiguredPlatformAdmin`, `normalizePlatformAdminEmail`, `PLATFORM_ADMIN_EMAIL_SEPARATOR`, `ADMIN_STANDING_NON_TABLE_INPUTS` and the test hooks beside them. `@objectstack/core` now depends on `@objectstack/types` (measured acyclic: `types` depends only on `spec`).

`@objectstack/plugin-auth`'s break-glass guard follows the derivation, as it must: `ADMIN_STANDING_SURFACE.sys_user` is reclassified `derives`, the last-administrator enumeration counts config-derived administrators through the resolver's own predicate, and a fifth write shape is judged — a change of address or an `email_verified` reset that would leave the environment with no administrator is refused, naming the configuration as the remedy. An ordinary profile write still costs the guard no reads.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,7 @@
},
"dependencies": {
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"zod": "^4.4.3"
},
"keywords": [
Expand Down
115 changes: 101 additions & 14 deletions packages/core/src/security/admin-standing-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,7 @@
import { describe, it, expect } from 'vitest';

import { ADMIN_STANDING_SURFACE, adminStandingTables } from './admin-standing-surface.js';
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
import { resolveAuthzContext } from './resolve-authz-context.js';

/** table -> every column name the resolver touched on it. */
Expand DownExpand Up@@ -129,7 +130,24 @@ const NOW = Date.parse('2026-08-15T00:00:00.000Z');
* Fixture variants, each reaching the platform-admin derivation and each
* deliberately taking a different side of the resolver's conditional reads.
*/
const VARIANTS: Record<string, { tables: Record<string, Array<Record<string, unknown>>>; org?: string }> = {
const VARIANTS: Record<
string,
{
tables: Record<string, Array<Record<string, unknown>>>;
org?: string;
/**
* [#11663 L2] `OS_PLATFORM_OWNER_EMAIL` for this variant. The config anchor
* reads `sys_user.email` and `sys_user.email_verified` ONLY when the
* deployment declared administrators (pin P2 — an empty list answers
* "not an admin" before touching any row), so those two columns are
* invisible to every fixture that leaves the variable unset. That is
* exactly the "a conditional read is invisible in a fixture that never
* takes the branch" hazard this file's header names, so the branch gets a
* variant of its own.
*/
platformAdminEmails?: string;
}
> = {
// Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path.
'snake-case rows, standing intact': {
org: 'org_1',
Expand DownExpand Up@@ -241,17 +259,64 @@ const VARIANTS: Record<string, { tables: Record<string, Array<Record<string, unk
],
},
},

// [#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
// columns are read at all, which is why the union needs it — without it the
// declaration below would have to omit them and the correspondence gate in
// plugin-auth would stop demanding a disposition for the very columns a
// write can revoke standing through.
'config-anchored platform admin': {
platformAdminEmails: 'ada@example.com',
tables: {
sys_user: [
{ id: 'usr_1', email: 'ada@example.com', email_verified: true, ai_access: 0 },
],
sys_member: [],
sys_user_position: [],
sys_position: [],
sys_position_permission_set: [],
sys_user_permission_set: [],
sys_permission_set: [],
},
},
};

/**
* Run `body` with `OS_PLATFORM_OWNER_EMAIL` set to exactly `value` (deleted when
* `undefined`), and the config memo dropped on BOTH sides.
*
* The memo is keyed on the raw string, so a worker that has already resolved
* one value would otherwise answer the next variant from the previous one's
* parse. Restoring the ambient value matters too: this suite must not decide
* what the rest of the worker's tests see.
*/
async function withPlatformAdminEmails<T>(value: string | undefined, body: () => Promise<T>): Promise<T> {
const prev = process.env.OS_PLATFORM_OWNER_EMAIL;
if (value === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = value;
resetPlatformAdminEmailMemo();
try {
return await body();
} finally {
if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
else process.env.OS_PLATFORM_OWNER_EMAIL = prev;
resetPlatformAdminEmailMemo();
}
}

async function observe(variant: keyof typeof VARIANTS): Promise<Observation> {
const seen: Observation = new Map();
const { tables, org } = VARIANTS[variant];
await resolveAuthzContext({
ql: makeRecordingQl(tables, seen),
headers: headers(),
getSession: sessionFor('usr_1', org),
nowMs: NOW,
});
const { tables, org, platformAdminEmails } = VARIANTS[variant]!;
await withPlatformAdminEmails(platformAdminEmails, () =>
resolveAuthzContext({
ql: makeRecordingQl(tables, seen),
headers: headers(),
getSession: sessionFor('usr_1', org),
nowMs: NOW,
}),
);
return seen;
}

Expand DownExpand Up@@ -291,12 +356,14 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re
);

it('reaches the platform-admin derivation — otherwise the observation proves nothing', async () => {
const ctx = await resolveAuthzContext({
ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()),
headers: headers(),
getSession: sessionFor('usr_1', 'org_1'),
nowMs: NOW,
});
const ctx = await withPlatformAdminEmails(undefined, () =>
resolveAuthzContext({
ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()),
headers: headers(),
getSession: sessionFor('usr_1', 'org_1'),
nowMs: NOW,
}),
);
// A positive control on the fixture itself: if the happy variant ever stops
// resolving a platform admin, every column below it goes unobserved and the
// equality above starts passing over a path nothing walked.
Expand All@@ -316,6 +383,26 @@ describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually re
expect(new Set(perVariant.values()).size).toBe(perVariant.size);
});

it('[#11663 L2] the config variant reaches the CONFIG anchor, not a grant', async () => {
// The second positive control, for the second anchor. Without it the two
// new sys_user columns could go unobserved (the branch never taken) and the
// equality above would start passing over a path nothing walked — the exact
// shape the fixture-variant note at the top of this file warns about.
const v = VARIANTS['config-anchored platform admin']!;
const ctx = await withPlatformAdminEmails(v.platformAdminEmails, () =>
resolveAuthzContext({
ql: makeRecordingQl(v.tables, new Map()),
headers: headers(),
getSession: sessionFor('usr_1', v.org),
nowMs: NOW,
}),
);
expect(ctx.posture).toBe('PLATFORM_ADMIN');
expect(ctx.positions).toContain('platform_admin');
// …and it really is the config route: there is no grant row in the fixture.
expect(v.tables.sys_user_permission_set).toEqual([]);
});

it('every declared table carries a reason, and only deriving tables carry columns', () => {
for (const [table, entry] of Object.entries(ADMIN_STANDING_SURFACE)) {
expect(entry.reason.length, `${table} needs a reason`).toBeGreaterThan(40);
Expand Down
97 changes: 86 additions & 11 deletions packages/core/src/security/admin-standing-surface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,20 @@
* the table-level half of the same guarantee: a resolver that starts deriving
* administrator standing from a new table would otherwise be invisible to a
* column-set comparison, because the new table appears in neither side's list.
*
* ## ⚠️ Tables are no longer the whole surface (#11663 L2)
*
* Since the platform-admin re-anchor's core leg, one input to the administrator
* derivation is NOT a table at all: the deployment's declared administrator
* list, read from the environment on every resolution
* (`security/platform-admin.ts`). A file that listed only tables would go on
* being perfectly accurate about the tables while silently claiming the
* derivation reads nothing else — the same shape as the stale comment this file
* replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place
* that says so, and it is deliberately a SEPARATE export rather than a
* pseudo-row in the table map: the map is compared for equality against
* observed table reads, and a pseudo-row would have to be excluded from that
* comparison by name, which is exactly the kind of special case that rots.
*/

/** How a table this resolver reads relates to "who is an administrator". */
Expand DownExpand Up@@ -83,17 +97,25 @@ export interface AdminStandingTable {
* principal, and therefore all of `resolveUserAuthzGrants`. The API-key
* 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` (§6b)
* is set only from a `sys_permission_set` row reached through an UNSCOPED
* `sys_user_permission_set` grant, never from a scope string.
* 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`.
*/
export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>> = {
sys_permission_set: {
role: 'derives',
reason:
'The row `platform_admin` is resolved BY NAME from (§6b). Renaming it, deleting it or '
+ 'switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform '
+ 'admin at once, with no identity table touched.',
'The row `admin_full_access` is resolved BY NAME from (§6b) — `platform_admin` is the '
+ "POSITION that row derives, not the row's own name. Renaming it, deleting it or switching "
+ 'it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform '
+ 'admin at once, with no identity table touched. ⚠️ It does NOT un-make a CONFIG-derived '
+ 'one (§6b-config, #11970): that route sets the same standing from '
+ "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own "
+ 'stored `sys_user` row, so it touches an identity table and never reads this one. With '
+ '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, '
+ 'this row stops being the single point that un-makes every administrator.',
columns: [
'id',
'name',
Expand DownExpand Up@@ -146,12 +168,25 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
},

sys_user: {
role: 'reads-only',
role: 'derives',
reason:
'Read for the `current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (§7). '
+ 'Neither confers administrator standing. The guard does watch this table, but for the '
+ 'ban/delete WRITE SHAPES — `banned` is never read here, so it is not a derivation column '
+ 'and carries no standing-key list.',
'[#11663 L2] RECLASSIFIED from `reads-only`. This table used to be read only for the '
+ '`current_user.email` RLS fallback and the ADR-0024 `ai_seat` synthesis (§7), and the '
+ 'note here said so: "Neither confers administrator standing." That sentence is now FALSE. '
+ 'The config anchor (§6b-config) matches the row\'s own `email` against the deployment\'s '
+ 'declared administrator list and requires `email_verified` to read verified, so a write '
+ 'that changes either column takes platform-admin standing away from a config-derived '
+ 'administrator — an address change and an email_verified reset are both ordinary, '
+ 'reachable writes, and neither touches a grant table. `banned` stays absent from the '
+ 'column list because the resolver still never reads it; the guard watches the ban/delete '
+ 'WRITE SHAPES on this table for its own reasons, which is a different question from what '
+ 'this resolver consumes.',
columns: [
'id',
'email',
'email_verified',
'ai_access',
],
},

sys_user_position: {
Expand DownExpand Up@@ -180,6 +215,46 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
},
};

/** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */
export interface AdminStandingNonTableInput {
/** How the value reaches the resolver, e.g. `env` for a process environment variable. */
readonly kind: 'env';
/** The exact spelling an operator sets — quotable verbatim in a refusal message. */
readonly name: string;
/** What it decides, and what a break-glass guard can and cannot do about it. */
readonly reason: string;
}

/**
* [#11663 L2] Inputs to the administrator derivation that no table write can
* reach — declared here so this file's silence about them cannot be read as
* "the derivation reads only tables".
*
* The practical consequence is the one worth writing down: a break-glass guard
* simulates a pending WRITE, and there is no write to simulate for any of
* these. Standing that rests on one of them is taken away by changing the
* deployment's configuration and rolling the process, which is deliberately
* outside every in-product path — including every path an agent could be talked
* into calling. That is the whole point of the config anchor, and it is also
* the reason a guard cannot promise to prevent this class of lockout: it can
* only refuse the writes it can see.
*/
export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[] = [
{
kind: 'env',
name: 'OS_PLATFORM_OWNER_EMAIL',
reason:
'The deployment\'s declared platform administrator(s) — one address or a comma-separated '
+ 'list, matched case-insensitively against `sys_user.email` and conferring standing only '
+ 'when that row\'s `email_verified` reads verified (§6b-config). Read live on every '
+ 'derivation with a per-process memo keyed on the raw string, so a rolled process picks up '
+ 'a change with no special path. Unset, blank, or carrying any unparseable entry means '
+ 'ZERO config-derived administrators, fail closed. No runtime write reaches it, so no '
+ 'break-glass guard can simulate a change to it: revocation is a configuration change plus '
+ 'a process roll, by design.',
},
];

/** The tables a write to which can change who is an administrator. */
export function adminStandingTables(): string[] {
return Object.entries(ADMIN_STANDING_SURFACE)
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,11 +155,33 @@ export { isRowActive, type ActivatableRow } from './row-active.js';
// single source `plugin-auth`'s break-glass standing-key lists correspond to.
export {
ADMIN_STANDING_SURFACE,
ADMIN_STANDING_NON_TABLE_INPUTS,
adminStandingTables,
adminStandingColumns,
type AdminStandingTable,
type AdminStandingNonTableInput,
} from './admin-standing-surface.js';

// [#11663 L2] The DEPLOYMENT-CONFIG anchor for PLATFORM_ADMIN — parse,
// normalization and match predicate for `OS_PLATFORM_OWNER_EMAIL`, consumed by
// `resolve-authz-context.ts` §6b-config. Exported so the sibling legs
// (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.
export {
PLATFORM_ADMIN_EMAIL_SEPARATOR,
normalizePlatformAdminEmail,
parsePlatformAdminEmails,
resolvePlatformAdminEmails,
resetPlatformAdminEmailMemo,
matchesConfiguredPlatformAdmin,
reportLegacyPlatformAdminGrant,
resetLegacyPlatformAdminGrantReport,
setPlatformAdminConfigSink,
type PlatformAdminEmailConfig,
type PlatformAdminConfigSink,
} from './platform-admin.js';

// [#7678] ADR-0090 D5/D9 — the audience-binding suggestion `?status=` vocabulary,
// shared by the runtime dispatcher's `/security` domain and the live REST route.
export {
Expand Down
Loading
Loading