Repository navigation
Expand file tree
/
Copy pathadmin-standing-surface.test.ts
More file actions
451 lines (427 loc) · 18.7 KB
/
Copy pathadmin-standing-surface.test.ts
File metadata and controls
451 lines (427 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [commit f8eb73601] The FIRST of the two links that bind `plugin-auth`'s break-glass
* standing-key lists to what this resolver actually reads.
*
* This half answers one question mechanically: **which columns does
* `resolveAuthzContext` read on the tables administrator standing is derived
* from?** It answers it by OBSERVATION — the real resolver is driven over a
* recording engine that returns `Proxy`-wrapped rows and records every property
* access, plus every `where` key, per table — and asserts the answer equals
* `ADMIN_STANDING_SURFACE`.
*
* The second link lives in `plugin-auth`
* (`last-admin-standing-keys.test.ts`): it requires every column declared here
* to be either in a standing-key list or explicitly excluded with a reason. So
* a resolver change that starts reading a new column fails HERE until the
* declaration is updated, and fails THERE until the guard has an answer for it.
*
* ## Why observation rather than a static extractor
*
* A source-parsing gate would have to follow `psRowsAll` into `psRows` into the
* `for (const ps of psRows)` loop to learn that `ps.name` is a read of
* `sys_permission_set.name` — real dataflow analysis, brittle in exactly the
* places that matter. Worse, it would have to inline the helpers: `active` is
* never named on the resolver's own call site (`isRowActive(r)` reads it), and
* neither is `valid_from` (`isGrantActive(row, now)` reads it). #8613's whole
* defect was a read that moved into a predicate; a gate that reads the caller
* and not the callee would have missed it for the same reason the comment did.
*
* ## Why the fixtures come in variants
*
* A conditional read is invisible in a fixture that never takes the branch. The
* resolver's tolerated-spelling chains are the sharp case: `r.organization_id ??
* r.organizationId` never touches the camelCase spelling while the snake_case
* one is non-nullish. So the observation is the UNION over fixtures chosen to
* take both sides of every such chain — snake-only rows, camel-only rows, and a
* pass where the flags and windows are set the other way. `assertVariantsStay-
* Distinct` keeps that honest: if two variants ever observe the same set, one of
* them has stopped contributing and the union has silently narrowed.
*/
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';
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
/** table -> every column name the resolver touched on it. */
type Observation = Map<string, Set<string>>;
const camelOf = (key: string): string => key.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase());
/**
* An ObjectQL stand-in that records what the resolver READS.
*
* Two rules keep the recording faithful:
*
* - the `where` match runs against the RAW row, never the proxy, so the fake
* driver's own reads are not mistaken for the resolver's;
* - `where` keys ARE recorded, because filtering on a column is reading it —
* a resolver that started passing `where: { active: true }` would be
* consuming `active` just as surely as `isRowActive` does.
*
* The matcher resolves a column through either spelling so a camelCase-only
* fixture still matches a snake_case `where`; that leniency is the harness's,
* never the resolver's, and it exists so the camel variant can reach the same
* code path rather than returning nothing.
*/
function makeRecordingQl(tables: Record<string, Array<Record<string, unknown>>>, seen: Observation) {
const note = (table: string, column: string): void => {
let cols = seen.get(table);
if (!cols) {
cols = new Set<string>();
seen.set(table, cols);
}
cols.add(column);
};
const raw = (row: Record<string, unknown>, key: string): unknown =>
key in row ? row[key] : row[camelOf(key)];
return bindCatalogFromTables({
async find(object: string, opts: { where?: Record<string, unknown>; limit?: number } = {}) {
if (!seen.has(object)) seen.set(object, new Set<string>());
const where = opts?.where ?? {};
for (const key of Object.keys(where)) {
if (!key.startsWith('$')) note(object, key);
}
const rows = (tables[object] ?? []).filter((row) =>
Object.entries(where).every(([key, cond]) => {
if (cond && typeof cond === 'object') {
const c = cond as Record<string, unknown>;
if ('$in' in c) return (c.$in as unknown[]).includes(raw(row, key));
if ('$nin' in c) return !(c.$nin as unknown[]).includes(raw(row, key));
if ('$ne' in c) return raw(row, key) !== c.$ne;
}
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
if (cond === null) return (raw(row, key) ?? null) === null;
return raw(row, key) === cond;
}),
);
// [commit 4c9780c7a] Enforce the caller's bound — presence, not truthiness, so
// `limit: 0` returns nothing rather than everything. Bounding BEFORE the
// Proxy wrap keeps the column-observation ledger honest: a row the real
// read would never have returned must not record column reads either.
const page = typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
return page.map(
(row) =>
new Proxy(row, {
get(target, prop, receiver) {
// `then` would make the row look thenable to an `await`; symbols
// are never column names.
if (typeof prop === 'string' && prop !== 'then') note(object, prop);
return Reflect.get(target, prop, receiver);
},
}),
);
},
}, tables);
}
const headers = () => new Headers();
const sessionFor = (userId: string, org?: string) => async () => ({
user: { id: userId, email: 'ada@example.com' },
session: { activeOrganizationId: org ?? null },
});
const HOUR = 3_600_000;
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;
/**
* [#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',
tables: {
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 1 }],
sys_member: [
{ id: 'mem_1', user_id: 'usr_1', organization_id: 'org_1', role: 'owner', valid_from: null, valid_until: null },
],
sys_user_position: [
{ id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: null, valid_from: null, valid_until: null },
],
sys_position: [{ id: 'pos_1', name: 'contributor', active: true }],
sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }],
sys_user_permission_set: [
{
id: 'ups_1',
user_id: 'usr_1',
permission_set_id: 'pst_1', permission_set: 'admin_full_access',
organization_id: null,
valid_from: null,
valid_until: null,
},
],
sys_permission_set: [
{
id: 'pst_1',
name: 'admin_full_access',
active: true,
system_permissions: ['view_all_records'],
tab_permissions: { setup: 'visible' },
},
{ id: 'pst_2', name: 'contributor_set', active: true },
],
},
},
// camelCase-only rows: every `snake ?? camel` chain must fall through to its
// second limb, which is the only way the camelCase spellings are observed.
'camelCase-only rows': {
org: 'org_1',
tables: {
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: true }],
sys_member: [{ id: 'mem_1', userId: 'usr_1', organizationId: 'org_1', role: 'admin' }],
sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor' }],
sys_position: [{ id: 'pos_1', name: 'contributor', active: true }],
sys_position_permission_set: [{ positionId: 'pos_1', permissionSetId: 'pst_2' }],
sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1' }],
sys_permission_set: [
{
id: 'pst_1',
name: 'admin_full_access',
active: true,
systemPermissions: ['view_all_records'],
tabPermissions: { setup: 'visible' },
},
{ id: 'pst_2', name: 'contributor_set', active: true },
],
},
},
// Standing taken away every way the resolver knows: the set switched off
// (ADR-0049, in the activation ledger), the grant scoped to an organization,
// the window closed (ADR-0091), and the JSON blobs stored as strings.
'standing revoked every way': {
org: 'org_1',
tables: {
sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 0 }],
sys_member: [
{
id: 'mem_1',
user_id: 'usr_1',
organization_id: 'org_1',
role: 'member',
valid_from: new Date(NOW - HOUR).toISOString(),
valid_until: new Date(NOW + HOUR).toISOString(),
},
],
sys_user_position: [
{
id: 'upo_1',
user_id: 'usr_1',
position: 'contributor',
organization_id: 'org_1',
valid_from: new Date(NOW - HOUR).toISOString(),
valid_until: new Date(NOW - 1).toISOString(),
},
],
sys_position: [{ id: 'pos_1', name: 'contributor', active: false }],
sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }],
sys_user_permission_set: [
{
id: 'ups_1',
user_id: 'usr_1',
permission_set_id: 'pst_1', permission_set: 'admin_full_access',
organization_id: 'org_1',
valid_from: new Date(NOW - HOUR).toISOString(),
valid_until: new Date(NOW + HOUR).toISOString(),
},
],
sys_permission_set: [
{
id: 'pst_1',
name: 'admin_full_access',
active: false,
system_permissions: JSON.stringify(['view_all_records']),
tab_permissions: JSON.stringify({ setup: 'visible' }),
},
{ id: 'pst_2', name: 'contributor_set', active: true },
],
// [ADR-0131 D3, ADR-0126 §4] The switch the resolver honours: the set row's
// own `active` above is no longer read.
sys_metadata_activation: [
{ id: 'act_1', metadata_type: 'permission', name: 'admin_full_access', package_id: null, active: false },
],
},
},
// [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
// 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, platformAdminEmails } = VARIANTS[variant]!;
await withPlatformAdminEmails(platformAdminEmails, () =>
resolveAuthzContext({
userAuthGates: undefined,
apiKeyLifecycle: undefined,
ql: makeRecordingQl(tables, seen),
headers: headers(),
getSession: sessionFor('usr_1', org),
nowMs: NOW,
}),
);
return seen;
}
async function observeAll(): Promise<Observation> {
const union: Observation = new Map();
for (const name of Object.keys(VARIANTS)) {
const seen = await observe(name);
for (const [table, cols] of seen) {
const into = union.get(table) ?? new Set<string>();
for (const col of cols) into.add(col);
union.set(table, into);
}
}
return union;
}
const sorted = (s: Iterable<string>): string[] => [...s].sort();
describe('[#8734] ADMIN_STANDING_SURFACE is what resolveAuthzContext actually reads', () => {
it('declares every table the resolution path reads — a new one must be classified', async () => {
const union = await observeAll();
expect(sorted(union.keys())).toEqual(sorted(Object.keys(ADMIN_STANDING_SURFACE)));
});
it.each(adminStandingTables())(
'declares exactly the columns read on %s',
async (table) => {
const union = await observeAll();
const observed = sorted(union.get(table) ?? []);
const declared = sorted(ADMIN_STANDING_SURFACE[table]!.columns ?? []);
// Equality, not containment, in BOTH directions on purpose. An undeclared
// read is the #8613 defect. A declared column nothing reads is the stale
// comment this file replaced, and left alone it would go on demanding a
// guard entry for a column that stopped mattering.
expect(observed).toEqual(declared);
},
);
it('reaches the platform-admin derivation — otherwise the observation proves nothing', async () => {
const ctx = await withPlatformAdminEmails(undefined, () =>
resolveAuthzContext({
userAuthGates: undefined,
apiKeyLifecycle: undefined,
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.
expect(ctx.positions).toContain('platform_admin');
expect(ctx.posture).toBe('PLATFORM_ADMIN');
});
it('keeps the variants distinct — a variant that stops contributing narrows the union silently', async () => {
const perVariant = new Map<string, string>();
for (const name of Object.keys(VARIANTS)) {
const seen = await observe(name);
const signature = sorted(seen.keys())
.map((t) => `${t}:${sorted(seen.get(t)!).join(',')}`)
.join('|');
perVariant.set(name, signature);
}
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({
userAuthGates: undefined,
apiKeyLifecycle: undefined,
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);
if (entry.role === 'derives') {
expect(entry.columns, `${table} derives standing and must declare its columns`).toBeDefined();
} else {
expect(entry.columns, `${table} reads only and must not declare columns`).toBeUndefined();
}
}
});
});