Skip to content

Commit c61ad20

Browse files
claude[bot]claude
andauthored
fix(plugin-security): fail closed on a non-object row in the admin promotion predicate (#12515) (#12556)
`bootstrapPlatformAdmin`'s local `isHumanUser` used a bare truthiness check followed by two property comparisons, so a truthy NON-object input scored human: `.id` and `.role` are both `undefined` on a non-object and both comparisons pass. The consolidated owner of the same question, `isHumanUserRow` in plugin-auth, requires `typeof row === 'object'` and answers non-human. The disagreement fell the wrong way on the copy that PERFORMS platform-admin promotion — it failed OPEN. Mirror `isHumanUserRow` exactly rather than inventing a stricter rule: over-tightening a promotion predicate means an install that cannot promote its first admin. Measured against a real SqlDriver over the shipped SysUser declaration first — every row a real `sys_user` read yields is a plain object, zero truthy non-objects, zero verdicts moved by the guard. The same guard already filters the byte-identical read in plugin-auth's dev-admin seed, so it is the incumbent on this population. Extend the cross-package agreement pin to the non-object class it previously had to exclude (it would have failed). The 14 existing agreement cases are unmoved. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 811a3c2 commit c61ad20

3 files changed

Lines changed: 184 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): fail CLOSED on a non-object row in the platform-admin promotion predicate (#12515)
6+
7+
`bootstrapPlatformAdmin`'s local `isHumanUser` decided "is this `sys_user` row a
8+
HUMAN?" with a bare truthiness check followed by two property comparisons:
9+
10+
```ts
11+
const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system';
12+
```
13+
14+
On a truthy NON-object input (`'usr_alice'`, a number, `true`) both comparisons
15+
read `undefined` and therefore both pass, so the input scored **human**. The
16+
same question's consolidated owner — `isHumanUserRow` in `@objectstack/plugin-auth`
17+
— requires `typeof row === 'object'` and answers **non-human** for those inputs.
18+
Two owners of one question, disagreeing, and the disagreement fell the wrong way
19+
on the security-critical side: this is the copy that performs the
20+
**platform-admin promotion**, so it failed OPEN. Its worst shape is the system
21+
account's own id arriving as a bare string, which the old spelling would have
22+
promoted.
23+
24+
The predicate now mirrors `isHumanUserRow` — the same `typeof` guard, and a real
25+
boolean return instead of echoing a falsy input back:
26+
27+
```ts
28+
const isHumanUser = (u: any) =>
29+
!!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system';
30+
```
31+
32+
**Why mirroring rather than a stricter rule of its own.** Over-tightening this
33+
predicate has a worse failure mode than the bug: an install that cannot promote
34+
its first admin is locked out of itself. The guard was therefore measured before
35+
it was chosen, not after. Against a real `SqlDriver` over the shipped `SysUser`
36+
declaration, every row a real `sys_user` read yields is a plain object — zero
37+
truthy non-objects, and zero rows whose verdict moves when the guard is added.
38+
The mirrored guard is also already the incumbent on this exact population:
39+
`plugin-auth`'s dev-admin seed filters the byte-identical read (`sys_user`,
40+
`where: {}`, `limit: 50`, system context) through `isHumanUserRow` today.
41+
42+
**No reachable behaviour changes.** The divergence is unreachable through any
43+
live call site, so this ships as a hardening of malformed-input handling rather
44+
than a behavioural fix. The 14 existing agreement cases in the cross-package
45+
pin are byte-for-byte unmoved; the pin gains the non-object class it previously
46+
had to exclude (it would have failed), which is what now stops the asymmetry
47+
returning — consolidating the two copies into a shared package stays declined,
48+
so nothing else was going to retire it.

packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131
* the legacy `usr_system` service row (`SystemUserId.SYSTEM` — no longer
3232
* provisioned, but present in every DB an older runtime created).
3333
*
34+
* ## Two populations, held apart on purpose
35+
*
36+
* {@link CORPUS} is the REACHABLE one: every entry is a shape a `sys_user`
37+
* read can really return, so a failure there is a live defect.
38+
* {@link NON_OBJECT_CORPUS} is the unreachable one — truthy non-objects, which
39+
* no real read yields. It was originally left out of this file because the two
40+
* owners genuinely disagreed on it and it would have failed; [#12515] closed
41+
* that disagreement by giving plugin-security the same `typeof` guard
42+
* `isHumanUserRow` already had, which is what made the class pinnable. The two
43+
* stay in separate arrays so the arrays keep saying different things: a red in
44+
* `CORPUS` means a reachable answer moved, a red in `NON_OBJECT_CORPUS` means
45+
* the fail-closed guard was dropped.
46+
*
3447
* ## Why the pin lives in plugin-auth and not in plugin-security
3548
*
3649
* Reaching both predicates from one test is a package-boundary problem, and
@@ -184,6 +197,59 @@ const CORPUS: { name: string; row: unknown }[] = [
184197
{ name: 'an undefined row', row: undefined },
185198
];
186199

200+
/**
201+
* The NON-OBJECT input class — held separately from {@link CORPUS} on purpose.
202+
*
203+
* ## Why it is a second array and not four more corpus entries
204+
*
205+
* `CORPUS`'s contract is that every entry is a shape a `sys_user` read can
206+
* really return, and these are not: a real read yields plain objects, measured
207+
* against a real `SqlDriver` over the shipped `SysUser` declaration. Filing
208+
* them into `CORPUS` would quietly falsify that promise and blur the one
209+
* distinction that decides how a failure here should be read.
210+
*
211+
* ## Why it is pinned at all, given it is unreachable
212+
*
213+
* This class is the gap the original pin deliberately left: it was excluded
214+
* because at the time it would have FAILED, not because it was uninteresting.
215+
* The two owners genuinely disagreed on it — `isHumanUserRow` requires
216+
* `typeof row === 'object'` and answered `false`, while plugin-security's
217+
* hand-spelled copy ran a bare truthiness check whose two property comparisons
218+
* are both `undefined` on a non-object and therefore both pass, answering
219+
* `true`. That direction fails OPEN on the copy that performs the
220+
* platform-admin promotion.
221+
*
222+
* Unreachable-today would be a fine reason to shrug if the asymmetry had a
223+
* scheduled end. It does not: consolidating the predicate into a package both
224+
* plugins depend on stays declined (it would widen a published surface), so
225+
* nothing is going to delete this divergence on its own. The guard closed it
226+
* instead, and this group is what stops it coming back — if a refactor ever
227+
* makes a non-object row reachable, or if the guard is dropped as noise, these
228+
* cases are the only mechanism that says so. Without them the pin sits green
229+
* through exactly the edit that reopens the hole.
230+
*
231+
* Both owners must answer NON-HUMAN here. That is the fail-closed direction,
232+
* and for a promotion predicate the safe answer to malformed input is "no".
233+
*/
234+
const NON_OBJECT_CORPUS: { name: string; row: unknown }[] = [
235+
{
236+
name: 'a bare id STRING where a row was expected',
237+
row: 'usr_alice',
238+
},
239+
{
240+
name: "the SYSTEM account's own id as a bare string — fail-open would promote the service account",
241+
row: SystemUserId.SYSTEM,
242+
},
243+
{ name: 'a number', row: 42 },
244+
{ name: 'the boolean true', row: true },
245+
{
246+
name: 'a function — truthy, and every property read on it is undefined',
247+
row: () => 'not a row',
248+
},
249+
{ name: 'the number zero — falsy, so the decision already agreed', row: 0 },
250+
{ name: 'an empty string — falsy, so the decision already agreed', row: '' },
251+
];
252+
187253
describe('human-user predicate agreement — plugin-security `isHumanUser` vs plugin-auth `isHumanUserRow`', () => {
188254
const saved: Record<string, string | undefined> = {};
189255
const PINNED_ENV = ['OS_TENANCY_POSTURE', 'OS_PLATFORM_OWNER_EMAIL'];
@@ -239,6 +305,56 @@ describe('human-user predicate agreement — plugin-security `isHumanUser` vs pl
239305
expect(CORPUS.map(({ row }) => isHumanUserRow(row)).some((v) => !v)).toBe(true);
240306
});
241307

308+
describe('the non-object input class — unreachable today, and fail-CLOSED on both sides', () => {
309+
for (const { name, row } of NON_OBJECT_CORPUS) {
310+
it(`agrees on ${name}`, async () => {
311+
const authSays = isHumanUserRow(row);
312+
const security = await securityVerdict(row);
313+
314+
// Stated as an absolute, not just as agreement: two predicates could
315+
// agree by both failing OPEN, which is the outcome this group exists
316+
// to forbid. `isHumanUserRow` is asserted false first so a regression
317+
// in the OWNER cannot be laundered into "well, they still agree".
318+
expect(
319+
authSays,
320+
`plugin-auth isHumanUserRow must answer NON-HUMAN for a non-object row.\n` +
321+
` row: ${String(row)} (typeof ${typeof row})`,
322+
).toBe(false);
323+
324+
expect(
325+
security.human,
326+
`plugin-security and plugin-auth disagree on a NON-OBJECT row — the security\n` +
327+
`copy is failing OPEN on malformed input, and it is the copy that PERFORMS\n` +
328+
`platform-admin promotion.\n` +
329+
` row: ${String(row)} (typeof ${typeof row})\n` +
330+
` plugin-auth isHumanUserRow -> ${authSays}\n` +
331+
` plugin-security isHumanUser -> ${security.human} (reason: ${security.reason ?? 'none'})\n` +
332+
`The fix is the \`typeof\` guard in bootstrap-platform-admin.ts, mirroring\n` +
333+
`isHumanUserRow — not a relaxation of this expectation.`,
334+
).toBe(false);
335+
336+
// Same anti-vacuity guard the reachable corpus uses: only the human
337+
// filter reaches `no_users`, so this proves the negative came from the
338+
// predicate rather than from a harness that broke earlier.
339+
expect(security.reason, 'negative verdict did not come from the human filter').toBe(
340+
'no_users',
341+
);
342+
});
343+
}
344+
345+
it('anti-vacuity: this group really carries truthy non-objects, not just falsy ones', () => {
346+
// A falsy row is non-human on both sides even with the guard removed, so
347+
// a group that had quietly lost its truthy members would keep passing
348+
// through the very regression it is here to catch.
349+
const truthyNonObjects = NON_OBJECT_CORPUS.filter(
350+
({ row }) => Boolean(row) && typeof row !== 'object',
351+
);
352+
expect(truthyNonObjects.length, 'no truthy non-object rows left in the group').toBeGreaterThan(
353+
0,
354+
);
355+
});
356+
});
357+
242358
it('the legacy usr_system row alone leaves the install with NO admin and awaiting a human', async () => {
243359
// The card's harm model, stated as an outcome rather than a predicate call:
244360
// a DB carrying only the legacy service row must be "no humans yet" on BOTH

packages/plugins/plugin-security/src/bootstrap-platform-admin.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,26 @@ export async function bootstrapPlatformAdmin(
398398
// it is the earliest user and steals the platform-admin promotion, leaving
399399
// the real admin login without `setup.access` / `studio.access` (Setup and
400400
// Studio then stay invisible even though login succeeds).
401-
const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system';
401+
//
402+
// [#12515] The `typeof` guard mirrors `isHumanUserRow`
403+
// (`plugin-auth/src/audience-posture.ts`) — the #11767-consolidated owner of
404+
// this same question — rather than inventing a stricter rule of its own.
405+
// Without it a truthy NON-object input (`'usr_alice'`, a number, `true`)
406+
// scores HUMAN here: `.id` and `.role` are both `undefined` on a non-object,
407+
// so both comparisons pass. `isHumanUserRow` calls that same input non-human,
408+
// and this is the copy that PERFORMS the platform-admin promotion — so the
409+
// divergence failed OPEN on the security-critical side, which is why this
410+
// copy moves rather than the other. `!!` completes the mirror: both now
411+
// return a real boolean instead of echoing a falsy input back.
412+
//
413+
// Direction checked before tightening, because over-tightening here would
414+
// mean an install unable to promote its first admin: every row a real
415+
// `sys_user` read yields is a plain object, so the guard changes no
416+
// reachable answer. The identical read (`sys_user`, `where: {}`, `limit: 50`,
417+
// system context) is already filtered by `isHumanUserRow` in `plugin-auth`'s
418+
// dev-admin seed, so this guard is the incumbent on this very population.
419+
const isHumanUser = (u: any) =>
420+
!!u && typeof u === 'object' && u.id !== SystemUserId.SYSTEM && u.role !== 'system';
402421
const oldestOf = (users: any[]) =>
403422
[...users].sort((a, b) => {
404423
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;

0 commit comments

Comments
 (0)