Skip to content

Commit 33184fd

Browse files
claude[bot]claude
andauthored
feat(core): anchor PLATFORM_ADMIN on a verified OS_PLATFORM_OWNER_EMAIL match, inside the one derivation site (#13146)
* wip: platform-admin config anchor (L2) * wip: tests for the config anchor * wip: changeset, vitest alias, tsconfig paths * test(core): make the platform-admin-config double refuse top-level combinators check:where-matcher graded the new fixture's matches() as silently wrong: with no combinator branch it read $or as a field name, compared row.$or (undefined) against the array and excluded the row, leaving the suite asserting on an empty result with nothing erroring. Refuses instead of implementing, which is what most of this repo's conforming doubles do and what the sibling batch-equivalence double already spells. $in stays supported: it is a per-field value operator the resolver really issues, not a top-level combinator. * docs(core): the sys_permission_set standing reason named the position, not the row Two defects in one sentence, both pre-existing on main: - it said the row `platform_admin` is resolved by name; the row is `admin_full_access` and `platform_admin` is the POSITION that row derives (resolve-authz-context.ts:594 matches the row, :665-666 unshifts the position); - 'un-makes every platform admin at once' stopped being true for a configured deployment: the config anchor sets the same standing off the caller's own sys_user row and never reads this table. A flat replacement would only swap which half is wrong, so the reason is now conditional and states the condition -- true whether or not OS_PLATFORM_OWNER_EMAIL is declared. Reason string only; role, columns and every executable path are untouched. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ff01cf commit 33184fd

15 files changed

Lines changed: 1751 additions & 40 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
`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).
7+
8+
**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.
9+
10+
**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.
11+
12+
**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.
13+
14+
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`).
15+
16+
`@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.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
"dependencies": {
3535
"@objectstack/spec": "workspace:*",
36+
"@objectstack/types": "workspace:*",
3637
"zod": "^4.4.3"
3738
},
3839
"keywords": [

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

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import { describe, it, expect } from 'vitest';
4444

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

4849
/** table -> every column name the resolver touched on it. */
@@ -129,7 +130,24 @@ const NOW = Date.parse('2026-08-15T00:00:00.000Z');
129130
* Fixture variants, each reaching the platform-admin derivation and each
130131
* deliberately taking a different side of the resolver's conditional reads.
131132
*/
132-
const VARIANTS: Record<string, { tables: Record<string, Array<Record<string, unknown>>>; org?: string }> = {
133+
const VARIANTS: Record<
134+
string,
135+
{
136+
tables: Record<string, Array<Record<string, unknown>>>;
137+
org?: string;
138+
/**
139+
* [#11663 L2] `OS_PLATFORM_OWNER_EMAIL` for this variant. The config anchor
140+
* reads `sys_user.email` and `sys_user.email_verified` ONLY when the
141+
* deployment declared administrators (pin P2 — an empty list answers
142+
* "not an admin" before touching any row), so those two columns are
143+
* invisible to every fixture that leaves the variable unset. That is
144+
* exactly the "a conditional read is invisible in a fixture that never
145+
* takes the branch" hazard this file's header names, so the branch gets a
146+
* variant of its own.
147+
*/
148+
platformAdminEmails?: string;
149+
}
150+
> = {
133151
// Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path.
134152
'snake-case rows, standing intact': {
135153
org: 'org_1',
@@ -241,17 +259,64 @@ const VARIANTS: Record<string, { tables: Record<string, Array<Record<string, unk
241259
],
242260
},
243261
},
262+
263+
// [#11663 L2] The CONFIG anchor. No grant row anywhere: standing comes from
264+
// the declared administrator list matched against this row's own `email`,
265+
// gated on `email_verified`. This is the only variant in which those two
266+
// columns are read at all, which is why the union needs it — without it the
267+
// declaration below would have to omit them and the correspondence gate in
268+
// plugin-auth would stop demanding a disposition for the very columns a
269+
// write can revoke standing through.
270+
'config-anchored platform admin': {
271+
platformAdminEmails: 'ada@example.com',
272+
tables: {
273+
sys_user: [
274+
{ id: 'usr_1', email: 'ada@example.com', email_verified: true, ai_access: 0 },
275+
],
276+
sys_member: [],
277+
sys_user_position: [],
278+
sys_position: [],
279+
sys_position_permission_set: [],
280+
sys_user_permission_set: [],
281+
sys_permission_set: [],
282+
},
283+
},
244284
};
245285

286+
/**
287+
* Run `body` with `OS_PLATFORM_OWNER_EMAIL` set to exactly `value` (deleted when
288+
* `undefined`), and the config memo dropped on BOTH sides.
289+
*
290+
* The memo is keyed on the raw string, so a worker that has already resolved
291+
* one value would otherwise answer the next variant from the previous one's
292+
* parse. Restoring the ambient value matters too: this suite must not decide
293+
* what the rest of the worker's tests see.
294+
*/
295+
async function withPlatformAdminEmails<T>(value: string | undefined, body: () => Promise<T>): Promise<T> {
296+
const prev = process.env.OS_PLATFORM_OWNER_EMAIL;
297+
if (value === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
298+
else process.env.OS_PLATFORM_OWNER_EMAIL = value;
299+
resetPlatformAdminEmailMemo();
300+
try {
301+
return await body();
302+
} finally {
303+
if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
304+
else process.env.OS_PLATFORM_OWNER_EMAIL = prev;
305+
resetPlatformAdminEmailMemo();
306+
}
307+
}
308+
246309
async function observe(variant: keyof typeof VARIANTS): Promise<Observation> {
247310
const seen: Observation = new Map();
248-
const { tables, org } = VARIANTS[variant];
249-
await resolveAuthzContext({
250-
ql: makeRecordingQl(tables, seen),
251-
headers: headers(),
252-
getSession: sessionFor('usr_1', org),
253-
nowMs: NOW,
254-
});
311+
const { tables, org, platformAdminEmails } = VARIANTS[variant]!;
312+
await withPlatformAdminEmails(platformAdminEmails, () =>
313+
resolveAuthzContext({
314+
ql: makeRecordingQl(tables, seen),
315+
headers: headers(),
316+
getSession: sessionFor('usr_1', org),
317+
nowMs: NOW,
318+
}),
319+
);
255320
return seen;
256321
}
257322

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

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

386+
it('[#11663 L2] the config variant reaches the CONFIG anchor, not a grant', async () => {
387+
// The second positive control, for the second anchor. Without it the two
388+
// new sys_user columns could go unobserved (the branch never taken) and the
389+
// equality above would start passing over a path nothing walked — the exact
390+
// shape the fixture-variant note at the top of this file warns about.
391+
const v = VARIANTS['config-anchored platform admin']!;
392+
const ctx = await withPlatformAdminEmails(v.platformAdminEmails, () =>
393+
resolveAuthzContext({
394+
ql: makeRecordingQl(v.tables, new Map()),
395+
headers: headers(),
396+
getSession: sessionFor('usr_1', v.org),
397+
nowMs: NOW,
398+
}),
399+
);
400+
expect(ctx.posture).toBe('PLATFORM_ADMIN');
401+
expect(ctx.positions).toContain('platform_admin');
402+
// …and it really is the config route: there is no grant row in the fixture.
403+
expect(v.tables.sys_user_permission_set).toEqual([]);
404+
});
405+
319406
it('every declared table carries a reason, and only deriving tables carry columns', () => {
320407
for (const [table, entry] of Object.entries(ADMIN_STANDING_SURFACE)) {
321408
expect(entry.reason.length, `${table} needs a reason`).toBeGreaterThan(40);

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

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@
5555
* the table-level half of the same guarantee: a resolver that starts deriving
5656
* administrator standing from a new table would otherwise be invisible to a
5757
* column-set comparison, because the new table appears in neither side's list.
58+
*
59+
* ## ⚠️ Tables are no longer the whole surface (#11663 L2)
60+
*
61+
* Since the platform-admin re-anchor's core leg, one input to the administrator
62+
* derivation is NOT a table at all: the deployment's declared administrator
63+
* list, read from the environment on every resolution
64+
* (`security/platform-admin.ts`). A file that listed only tables would go on
65+
* being perfectly accurate about the tables while silently claiming the
66+
* derivation reads nothing else — the same shape as the stale comment this file
67+
* replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place
68+
* that says so, and it is deliberately a SEPARATE export rather than a
69+
* pseudo-row in the table map: the map is compared for equality against
70+
* observed table reads, and a pseudo-row would have to be excluded from that
71+
* comparison by name, which is exactly the kind of special case that rots.
5872
*/
5973

6074
/** How a table this resolver reads relates to "who is an administrator". */
@@ -83,17 +97,25 @@ export interface AdminStandingTable {
8397
* principal, and therefore all of `resolveUserAuthzGrants`. The API-key
8498
* ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
8599
* authenticates a principal and seeds `permissions` with the key's scopes, and
86-
* confers no administrator standing of its own — `hasPlatformAdminGrant` (§6b)
87-
* is set only from a `sys_permission_set` row reached through an UNSCOPED
88-
* `sys_user_permission_set` grant, never from a scope string.
100+
* confers no administrator standing of its own — `hasPlatformAdminGrant` is set
101+
* from a `sys_permission_set` row reached through an UNSCOPED
102+
* `sys_user_permission_set` grant (§6b) or from the deployment config matched
103+
* against the caller's own STORED `sys_user` row (§6b-config), never from a
104+
* scope string and never from the caller-seedable `grants.email`.
89105
*/
90106
export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>> = {
91107
sys_permission_set: {
92108
role: 'derives',
93109
reason:
94-
'The row `platform_admin` is resolved BY NAME from (§6b). Renaming it, deleting it or '
95-
+ 'switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform '
96-
+ 'admin at once, with no identity table touched.',
110+
'The row `admin_full_access` is resolved BY NAME from (§6b) — `platform_admin` is the '
111+
+ "POSITION that row derives, not the row's own name. Renaming it, deleting it or switching "
112+
+ 'it off (ADR-0049 `active`, read here since #8613) un-makes every GRANT-derived platform '
113+
+ 'admin at once, with no identity table touched. ⚠️ It does NOT un-make a CONFIG-derived '
114+
+ 'one (§6b-config, #11970): that route sets the same standing from '
115+
+ "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own "
116+
+ 'stored `sys_user` row, so it touches an identity table and never reads this one. With '
117+
+ '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, '
118+
+ 'this row stops being the single point that un-makes every administrator.',
97119
columns: [
98120
'id',
99121
'name',
@@ -146,12 +168,25 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
146168
},
147169

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

157192
sys_user_position: {
@@ -180,6 +215,46 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
180215
},
181216
};
182217

218+
/** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */
219+
export interface AdminStandingNonTableInput {
220+
/** How the value reaches the resolver, e.g. `env` for a process environment variable. */
221+
readonly kind: 'env';
222+
/** The exact spelling an operator sets — quotable verbatim in a refusal message. */
223+
readonly name: string;
224+
/** What it decides, and what a break-glass guard can and cannot do about it. */
225+
readonly reason: string;
226+
}
227+
228+
/**
229+
* [#11663 L2] Inputs to the administrator derivation that no table write can
230+
* reach — declared here so this file's silence about them cannot be read as
231+
* "the derivation reads only tables".
232+
*
233+
* The practical consequence is the one worth writing down: a break-glass guard
234+
* simulates a pending WRITE, and there is no write to simulate for any of
235+
* these. Standing that rests on one of them is taken away by changing the
236+
* deployment's configuration and rolling the process, which is deliberately
237+
* outside every in-product path — including every path an agent could be talked
238+
* into calling. That is the whole point of the config anchor, and it is also
239+
* the reason a guard cannot promise to prevent this class of lockout: it can
240+
* only refuse the writes it can see.
241+
*/
242+
export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[] = [
243+
{
244+
kind: 'env',
245+
name: 'OS_PLATFORM_OWNER_EMAIL',
246+
reason:
247+
'The deployment\'s declared platform administrator(s) — one address or a comma-separated '
248+
+ 'list, matched case-insensitively against `sys_user.email` and conferring standing only '
249+
+ 'when that row\'s `email_verified` reads verified (§6b-config). Read live on every '
250+
+ 'derivation with a per-process memo keyed on the raw string, so a rolled process picks up '
251+
+ 'a change with no special path. Unset, blank, or carrying any unparseable entry means '
252+
+ 'ZERO config-derived administrators, fail closed. No runtime write reaches it, so no '
253+
+ 'break-glass guard can simulate a change to it: revocation is a configuration change plus '
254+
+ 'a process roll, by design.',
255+
},
256+
];
257+
183258
/** The tables a write to which can change who is an administrator. */
184259
export function adminStandingTables(): string[] {
185260
return Object.entries(ADMIN_STANDING_SURFACE)

packages/core/src/security/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,33 @@ export { isRowActive, type ActivatableRow } from './row-active.js';
155155
// single source `plugin-auth`'s break-glass standing-key lists correspond to.
156156
export {
157157
ADMIN_STANDING_SURFACE,
158+
ADMIN_STANDING_NON_TABLE_INPUTS,
158159
adminStandingTables,
159160
adminStandingColumns,
160161
type AdminStandingTable,
162+
type AdminStandingNonTableInput,
161163
} from './admin-standing-surface.js';
162164

165+
// [#11663 L2] The DEPLOYMENT-CONFIG anchor for PLATFORM_ADMIN — parse,
166+
// normalization and match predicate for `OS_PLATFORM_OWNER_EMAIL`, consumed by
167+
// `resolve-authz-context.ts` §6b-config. Exported so the sibling legs
168+
// (plugin-auth's break-glass guard, plugin-security's bootstrap, the audit
169+
// surface) ask the SAME question instead of re-implementing the parse — which
170+
// is the whole reason the config read has exactly one home.
171+
export {
172+
PLATFORM_ADMIN_EMAIL_SEPARATOR,
173+
normalizePlatformAdminEmail,
174+
parsePlatformAdminEmails,
175+
resolvePlatformAdminEmails,
176+
resetPlatformAdminEmailMemo,
177+
matchesConfiguredPlatformAdmin,
178+
reportLegacyPlatformAdminGrant,
179+
resetLegacyPlatformAdminGrantReport,
180+
setPlatformAdminConfigSink,
181+
type PlatformAdminEmailConfig,
182+
type PlatformAdminConfigSink,
183+
} from './platform-admin.js';
184+
163185
// [#7678] ADR-0090 D5/D9 — the audience-binding suggestion `?status=` vocabulary,
164186
// shared by the runtime dispatcher's `/security` domain and the live REST route.
165187
export {

0 commit comments

Comments
 (0)