Skip to content

Commit f8eb736

Browse files
Jack Qclaude
andauthored
feat(security): bind last-admin standing-key lists to the authz resolver's measured read surface (#8734) (#8801)
The break-glass guard's three standing-key lists are a cache of which columns resolveAuthzContext reads when deriving administrator standing. Nothing bound them together — the correspondence was a comment, and it had already gone false once (#6084's comment named 'active' as invisible; #8613 made it a resolution-time predicate). Two links replace the prose: 1. packages/core declares ADMIN_STANDING_SURFACE beside the resolver and asserts it EQUALS what the real resolveAuthzContext reads, observed at runtime through a recording engine (property accesses + where keys, per table). Observation rather than static extraction because the reads that matter live in helpers: isRowActive reads 'active', isGrantActive reads the ADR-0091 bounds. 2. plugin-auth exports its lists plus STANDING_KEYS_BY_TABLE / STANDING_KEY_EXCLUSIONS, and a gate requires every measured column to be either standing-bearing or excluded with a reason. No third state. No guard behaviour changes: every list keeps its exact values, and the gate is one-directional so it can only ever demand the guard judges more. Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04f8fdb commit f8eb736

6 files changed

Lines changed: 808 additions & 3 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-auth": minor
4+
---
5+
6+
feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
7+
8+
`plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
9+
pending write can empty the administrator population by testing the payload
10+
against three standing-key lists (`MEMBER_STANDING_KEYS`,
11+
`GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
12+
of them is skipped without any reads — so a column `resolveAuthzContext` starts
13+
reading that a list omits is a write class the guard **silently stops judging**,
14+
on the one path whose failure mode is an installation-wide administrator lockout
15+
with no in-product recovery.
16+
17+
Nothing bound the two together. The correspondence lived in a comment, and it
18+
had already gone false once: #6084 wrote — naming `active` explicitly — that
19+
everything a permission-set write touches other than `name` is invisible to "who
20+
is an administrator". That was true when written; #8613 made `active` a
21+
resolution-time predicate and the sentence became false. Nothing mechanical
22+
would have caught it, because the guard's own tests stay green precisely when
23+
the guard is never consulted.
24+
25+
**The mechanism is two links, and the first one is a measurement.**
26+
27+
- `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
28+
resolver, listing every table the administrator-derivation path reads, each
29+
classified `derives` or `reads-only` with its reason, and for the deriving
30+
tables every column read. It is asserted **equal** to what the real
31+
`resolveAuthzContext` reads, observed at runtime through a recording engine
32+
that records every property access and every `where` key per table. Observation
33+
rather than source extraction because the reads that matter have moved into
34+
helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
35+
by `isGrantActive(row, now)`, neither named at the resolver's own call site —
36+
the exact shape #8613 had.
37+
38+
- `@objectstack/plugin-auth` now exports its standing-key lists plus
39+
`STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
40+
every column of that measured surface to have an answer: it is standing-bearing
41+
(in a list) or it is excluded with the reason it cannot empty the administrator
42+
population. There is no third state — the third state is what `active` was
43+
between #6084 and #8613.
44+
45+
So a resolver change that starts reading a new column fails at the first link
46+
until the declaration is updated, and at the second until the guard has an
47+
explicit answer for it. Landing #8613 green would have required writing down that
48+
deactivating `admin_full_access` cannot empty the administrator population —
49+
which is false, and which is what the old comment asserted by accident.
50+
51+
**No guard behaviour changes.** Every list keeps exactly the values it had; the
52+
gate is one-directional by construction (it can only ever demand that the guard
53+
judges *more*), because the other direction would put pressure on a break-glass
54+
guard to fire less often.
55+
56+
The table-level half is covered too: a resolver that started deriving
57+
administrator standing from a **new** table is invisible to any column-set
58+
comparison, since the table is absent from both sides — so the surface enumerates
59+
every table the path reads, and an unclassified one fails.
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8734] The FIRST of the two links that bind `plugin-auth`'s break-glass
5+
* standing-key lists to what this resolver actually reads.
6+
*
7+
* This half answers one question mechanically: **which columns does
8+
* `resolveAuthzContext` read on the tables administrator standing is derived
9+
* from?** It answers it by OBSERVATION — the real resolver is driven over a
10+
* recording engine that returns `Proxy`-wrapped rows and records every property
11+
* access, plus every `where` key, per table — and asserts the answer equals
12+
* `ADMIN_STANDING_SURFACE`.
13+
*
14+
* The second link lives in `plugin-auth`
15+
* (`last-admin-standing-keys.test.ts`): it requires every column declared here
16+
* to be either in a standing-key list or explicitly excluded with a reason. So
17+
* a resolver change that starts reading a new column fails HERE until the
18+
* declaration is updated, and fails THERE until the guard has an answer for it.
19+
*
20+
* ## Why observation rather than a static extractor
21+
*
22+
* A source-parsing gate would have to follow `psRowsAll` into `psRows` into the
23+
* `for (const ps of psRows)` loop to learn that `ps.name` is a read of
24+
* `sys_permission_set.name` — real dataflow analysis, brittle in exactly the
25+
* places that matter. Worse, it would have to inline the helpers: `active` is
26+
* never named on the resolver's own call site (`isRowActive(r)` reads it), and
27+
* neither is `valid_from` (`isGrantActive(row, now)` reads it). #8613's whole
28+
* defect was a read that moved into a predicate; a gate that reads the caller
29+
* and not the callee would have missed it for the same reason the comment did.
30+
*
31+
* ## Why the fixtures come in variants
32+
*
33+
* A conditional read is invisible in a fixture that never takes the branch. The
34+
* resolver's tolerated-spelling chains are the sharp case: `r.organization_id ??
35+
* r.organizationId` never touches the camelCase spelling while the snake_case
36+
* one is non-nullish. So the observation is the UNION over fixtures chosen to
37+
* take both sides of every such chain — snake-only rows, camel-only rows, and a
38+
* pass where the flags and windows are set the other way. `assertVariantsStay-
39+
* Distinct` keeps that honest: if two variants ever observe the same set, one of
40+
* them has stopped contributing and the union has silently narrowed.
41+
*/
42+
43+
import { describe, it, expect } from 'vitest';
44+
45+
import { ADMIN_STANDING_SURFACE, adminStandingTables } from './admin-standing-surface.js';
46+
import { resolveAuthzContext } from './resolve-authz-context.js';
47+
48+
/** table -> every column name the resolver touched on it. */
49+
type Observation = Map<string, Set<string>>;
50+
51+
const camelOf = (key: string): string => key.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase());
52+
53+
/**
54+
* An ObjectQL stand-in that records what the resolver READS.
55+
*
56+
* Two rules keep the recording faithful:
57+
*
58+
* - the `where` match runs against the RAW row, never the proxy, so the fake
59+
* driver's own reads are not mistaken for the resolver's;
60+
* - `where` keys ARE recorded, because filtering on a column is reading it —
61+
* a resolver that started passing `where: { active: true }` would be
62+
* consuming `active` just as surely as `isRowActive` does.
63+
*
64+
* The matcher resolves a column through either spelling so a camelCase-only
65+
* fixture still matches a snake_case `where`; that leniency is the harness's,
66+
* never the resolver's, and it exists so the camel variant can reach the same
67+
* code path rather than returning nothing.
68+
*/
69+
function makeRecordingQl(tables: Record<string, Array<Record<string, unknown>>>, seen: Observation) {
70+
const note = (table: string, column: string): void => {
71+
let cols = seen.get(table);
72+
if (!cols) {
73+
cols = new Set<string>();
74+
seen.set(table, cols);
75+
}
76+
cols.add(column);
77+
};
78+
const raw = (row: Record<string, unknown>, key: string): unknown =>
79+
key in row ? row[key] : row[camelOf(key)];
80+
81+
return {
82+
async find(object: string, opts: { where?: Record<string, unknown> } = {}) {
83+
if (!seen.has(object)) seen.set(object, new Set<string>());
84+
const where = opts?.where ?? {};
85+
for (const key of Object.keys(where)) {
86+
if (!key.startsWith('$')) note(object, key);
87+
}
88+
const rows = (tables[object] ?? []).filter((row) =>
89+
Object.entries(where).every(([key, cond]) => {
90+
if (cond && typeof cond === 'object') {
91+
const c = cond as Record<string, unknown>;
92+
if ('$in' in c) return (c.$in as unknown[]).includes(raw(row, key));
93+
if ('$nin' in c) return !(c.$nin as unknown[]).includes(raw(row, key));
94+
if ('$ne' in c) return raw(row, key) !== c.$ne;
95+
}
96+
return raw(row, key) === cond;
97+
}),
98+
);
99+
return rows.map(
100+
(row) =>
101+
new Proxy(row, {
102+
get(target, prop, receiver) {
103+
// `then` would make the row look thenable to an `await`; symbols
104+
// are never column names.
105+
if (typeof prop === 'string' && prop !== 'then') note(object, prop);
106+
return Reflect.get(target, prop, receiver);
107+
},
108+
}),
109+
);
110+
},
111+
};
112+
}
113+
114+
const headers = () => new Headers();
115+
const sessionFor = (userId: string, org?: string) => async () => ({
116+
user: { id: userId, email: 'ada@example.com' },
117+
session: { activeOrganizationId: org ?? null },
118+
});
119+
120+
const HOUR = 3_600_000;
121+
const NOW = Date.parse('2026-08-15T00:00:00.000Z');
122+
123+
/**
124+
* Fixture variants, each reaching the platform-admin derivation and each
125+
* deliberately taking a different side of the resolver's conditional reads.
126+
*/
127+
const VARIANTS: Record<string, { tables: Record<string, Array<Record<string, unknown>>>; org?: string }> = {
128+
// Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path.
129+
'snake-case rows, standing intact': {
130+
org: 'org_1',
131+
tables: {
132+
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 1 }],
133+
sys_member: [
134+
{ id: 'mem_1', user_id: 'usr_1', organization_id: 'org_1', role: 'owner', valid_from: null, valid_until: null },
135+
],
136+
sys_user_position: [
137+
{ id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: null, valid_from: null, valid_until: null },
138+
],
139+
sys_position: [{ id: 'pos_1', name: 'contributor', active: true }],
140+
sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }],
141+
sys_user_permission_set: [
142+
{
143+
id: 'ups_1',
144+
user_id: 'usr_1',
145+
permission_set_id: 'pst_1',
146+
organization_id: null,
147+
valid_from: null,
148+
valid_until: null,
149+
},
150+
],
151+
sys_permission_set: [
152+
{
153+
id: 'pst_1',
154+
name: 'admin_full_access',
155+
active: true,
156+
system_permissions: ['view_all_records'],
157+
tab_permissions: { setup: 'visible' },
158+
},
159+
{ id: 'pst_2', name: 'contributor_set', active: true },
160+
],
161+
},
162+
},
163+
164+
// camelCase-only rows: every `snake ?? camel` chain must fall through to its
165+
// second limb, which is the only way the camelCase spellings are observed.
166+
'camelCase-only rows': {
167+
org: 'org_1',
168+
tables: {
169+
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: true }],
170+
sys_member: [{ id: 'mem_1', userId: 'usr_1', organizationId: 'org_1', role: 'admin' }],
171+
sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor' }],
172+
sys_position: [{ id: 'pos_1', name: 'contributor', active: true }],
173+
sys_position_permission_set: [{ positionId: 'pos_1', permissionSetId: 'pst_2' }],
174+
sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1' }],
175+
sys_permission_set: [
176+
{
177+
id: 'pst_1',
178+
name: 'admin_full_access',
179+
active: true,
180+
systemPermissions: ['view_all_records'],
181+
tabPermissions: { setup: 'visible' },
182+
},
183+
{ id: 'pst_2', name: 'contributor_set', active: true },
184+
],
185+
},
186+
},
187+
188+
// Standing taken away every way the resolver knows: the set switched off
189+
// (ADR-0049), the grant scoped to an organization, the window closed
190+
// (ADR-0091), the position deactivated, and the JSON blobs stored as strings.
191+
'standing revoked every way': {
192+
org: 'org_1',
193+
tables: {
194+
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 0 }],
195+
sys_member: [
196+
{
197+
id: 'mem_1',
198+
user_id: 'usr_1',
199+
organization_id: 'org_1',
200+
role: 'member',
201+
valid_from: new Date(NOW - HOUR).toISOString(),
202+
valid_until: new Date(NOW + HOUR).toISOString(),
203+
},
204+
],
205+
sys_user_position: [
206+
{
207+
id: 'upo_1',
208+
user_id: 'usr_1',
209+
position: 'contributor',
210+
organization_id: 'org_1',
211+
valid_from: new Date(NOW - HOUR).toISOString(),
212+
valid_until: new Date(NOW - 1).toISOString(),
213+
},
214+
],
215+
sys_position: [{ id: 'pos_1', name: 'contributor', active: false }],
216+
sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }],
217+
sys_user_permission_set: [
218+
{
219+
id: 'ups_1',
220+
user_id: 'usr_1',
221+
permission_set_id: 'pst_1',
222+
organization_id: 'org_1',
223+
valid_from: new Date(NOW - HOUR).toISOString(),
224+
valid_until: new Date(NOW + HOUR).toISOString(),
225+
},
226+
],
227+
sys_permission_set: [
228+
{
229+
id: 'pst_1',
230+
name: 'admin_full_access',
231+
active: false,
232+
system_permissions: JSON.stringify(['view_all_records']),
233+
tab_permissions: JSON.stringify({ setup: 'visible' }),
234+
},
235+
{ id: 'pst_2', name: 'contributor_set', active: true },
236+
],
237+
},
238+
},
239+
};
240+
241+
async function observe(variant: keyof typeof VARIANTS): Promise<Observation> {
242+
const seen: Observation = new Map();
243+
const { tables, org } = VARIANTS[variant];
244+
await resolveAuthzContext({
245+
ql: makeRecordingQl(tables, seen),
246+
headers: headers(),
247+
getSession: sessionFor('usr_1', org),
248+
nowMs: NOW,
249+
});
250+
return seen;
251+
}
252+
253+
async function observeAll(): Promise<Observation> {
254+
const union: Observation = new Map();
255+
for (const name of Object.keys(VARIANTS)) {
256+
const seen = await observe(name);
257+
for (const [table, cols] of seen) {
258+
const into = union.get(table) ?? new Set<string>();
259+
for (const col of cols) into.add(col);
260+
union.set(table, into);
261+
}
262+
}
263+
return union;
264+
}
265+
266+
const sorted = (s: Iterable<string>): string[] => [...s].sort();
267+
268+
describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually reads', () => {
269+
it('declares every table the resolution path reads — a new one must be classified', async () => {
270+
const union = await observeAll();
271+
expect(sorted(union.keys())).toEqual(sorted(Object.keys(ADMIN_STANDING_SURFACE)));
272+
});
273+
274+
it.each(adminStandingTables())(
275+
'declares exactly the columns read on %s',
276+
async (table) => {
277+
const union = await observeAll();
278+
const observed = sorted(union.get(table) ?? []);
279+
const declared = sorted(ADMIN_STANDING_SURFACE[table]!.columns ?? []);
280+
// Equality, not containment, in BOTH directions on purpose. An undeclared
281+
// read is the #8613 defect. A declared column nothing reads is the stale
282+
// comment this file replaced, and left alone it would go on demanding a
283+
// guard entry for a column that stopped mattering.
284+
expect(observed).toEqual(declared);
285+
},
286+
);
287+
288+
it('reaches the platform-admin derivation — otherwise the observation proves nothing', async () => {
289+
const ctx = await resolveAuthzContext({
290+
ql: makeRecordingQl(VARIANTS['snake-case rows, standing intact']!.tables, new Map()),
291+
headers: headers(),
292+
getSession: sessionFor('usr_1', 'org_1'),
293+
nowMs: NOW,
294+
});
295+
// A positive control on the fixture itself: if the happy variant ever stops
296+
// resolving a platform admin, every column below it goes unobserved and the
297+
// equality above starts passing over a path nothing walked.
298+
expect(ctx.positions).toContain('platform_admin');
299+
expect(ctx.posture).toBe('PLATFORM_ADMIN');
300+
});
301+
302+
it('keeps the variants distinct — a variant that stops contributing narrows the union silently', async () => {
303+
const perVariant = new Map<string, string>();
304+
for (const name of Object.keys(VARIANTS)) {
305+
const seen = await observe(name);
306+
const signature = sorted(seen.keys())
307+
.map((t) => `${t}:${sorted(seen.get(t)!).join(',')}`)
308+
.join('|');
309+
perVariant.set(name, signature);
310+
}
311+
expect(new Set(perVariant.values()).size).toBe(perVariant.size);
312+
});
313+
314+
it('every declared table carries a reason, and only deriving tables carry columns', () => {
315+
for (const [table, entry] of Object.entries(ADMIN_STANDING_SURFACE)) {
316+
expect(entry.reason.length, `${table} needs a reason`).toBeGreaterThan(40);
317+
if (entry.role === 'derives') {
318+
expect(entry.columns, `${table} derives standing and must declare its columns`).toBeDefined();
319+
} else {
320+
expect(entry.columns, `${table} reads only and must not declare columns`).toBeUndefined();
321+
}
322+
}
323+
});
324+
});

0 commit comments

Comments
 (0)